Compare commits

...

112 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 01:24:15 +09:00
ByungCheol d364b52c73 fix(account): wallets ref TDZ 오류 수정 — computed 선언 전 초기화되도록 위로 이동
Deploy / deploy (push) Failing after 14m35s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:56:14 +09:00
ByungCheol 0b64f4f8cd Merge branch 'dev'
Deploy / deploy (push) Failing after 11m10s
2026-07-02 01:50:09 +09:00
ByungCheol c0e5421c33 fix(account): AccountView.vue 첫 줄 잘못된 '1' 문자 제거
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 01:43:01 +09:00
ByungCheol 282fb7476d Merge branch 'dev'
Deploy / deploy (push) Failing after 12m58s
2026-07-02 00:31:53 +09:00
ByungCheol 1b53af525e feat(account): 대출 실행금액 필드 추가 + 상환방식별 자동계산 분기
CI / build (push) Failing after 14m30s
- loan_amount(대출 실행 금액) 컬럼 추가(DB/도메인/DTO/mapper)
- 계좌 폼: 대출 실행 금액 입력 / 기록 시작 시 잔액(기존 openingBalance) 레이블 분리
- 계좌 카드: 실행금액·금리·상환방식 표시
- 상환 자동계산 3방식 분기:
  - 원리금균등: 납입금액 입력 → 이자(잔액×월이율) / 원금(납입-이자) 분리
  - 원금균등: 실행금액÷기간=월원금, 잔액×월이율=이자 자동계산
  - 만기일시: 잔액×월이율=이자만 자동계산, 원금 0
- 자동계산 후 이자·원금 수동 조정 가능

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 00:32:59 +09:00
73 changed files with 7196 additions and 2167 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
# cache 제거: Gitea 캐시 서버 미연결 시 'Save cache' 후처리가 수 분 hang → '가짜 실패' 유발
- name: Install dependencies
run: npm ci
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
# cache 제거: Gitea 캐시 서버 미연결 시 'Save cache' 후처리가 수 분 hang → '가짜 실패' 유발
- name: Install
run: npm ci
+58 -31
View File
@@ -1,44 +1,71 @@
# sb_pt
# 돈돼지 가계부 — 프론트엔드 (sb_pt)
This template should help get you started developing with Vue 3 in Vite.
Vue 3 + Vite 기반의 가계부·자산·게시판 앱 프론트엔드. 웹 / 안드로이드(Capacitor) / Windows 데스크톱(Electron) 3개 플랫폼을 하나의 코드베이스로 빌드합니다.
## Recommended IDE Setup
- 서비스명 **돈돼지 가계부** (구 Slim Budget)
- 운영: [app.sblog.kr](https://app.sblog.kr) · 안내/정책 페이지 [www.sblog.kr](https://www.sblog.kr)
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
## 기술 스택
- **Vue 3** (`<script setup>` Composition API) + **Vite**
- **Pinia** (상태관리), **Vue Router** (라우팅 + 인증 가드)
- **Axios** (공통 인스턴스 + 토큰/401 인터셉터)
- **Toast UI Editor** + Prism.js (게시판 마크다운 작성/뷰어)
- **Capacitor** (안드로이드 하이브리드 앱), **Electron** (Windows 데스크톱)
- **Vitest** + `@vue/test-utils` (테스트)
## 빠른 시작
```sh
npm install
npm run dev # 개발 서버 (전체 앱 표시 — 웹 접근 제한 예외)
```
- Node: `^20.19.0 || >=22.12.0`
- API 베이스 URL: `VITE_API_BASE_URL` (기본 `/api`, Nginx 가 백엔드 8080 으로 프록시)
### Compile and Hot-Reload for Development
## 주요 스크립트
| 명령 | 설명 |
|---|---|
| `npm run dev` | 개발 서버 (HMR) |
| `npm run build` | `dist/` 정적 빌드 (웹 배포용) |
| `npm run build:app` | Capacitor 모드 빌드 (`--mode capacitor`, 앱 전용 env) |
| `npm run cap:sync` | 앱 빌드 + `cap sync` (안드로이드 동기화) |
| `npm run cap:android` | Android Studio 열기 |
| `npm run electron:dev` | Electron 데스크톱 실행 |
| `npm run electron:build` | Windows 설치파일(`DonDwaeji-Setup.exe`) 빌드 |
| `npm test` | Vitest 실행 |
| `npm run lint` | oxlint + eslint (`--fix`) |
```sh
npm run dev
```
## 플랫폼별 동작
- **웹(PC·모바일)**: 안내 페이지만 노출(앱 다운로드 유도). 실제 이용은 앱에서. (`npm run dev` 는 예외로 전체 앱 표시)
- **안드로이드**: Capacitor WebView 패키징. 빌드 전 `.env.capacitor` 의 백엔드 절대 URL 설정 필수 → [docs/ANDROID.md](docs/ANDROID.md)
- **Windows**: Electron 이 라이브 사이트(app.sblog.kr)를 직접 로드 → 프론트 변경이 재설치 없이 반영
### Compile and Minify for Production
## 인증
- 로그인 시 발급된 **Bearer 토큰**을 `localStorage` 에 저장, `http.js` 인터셉터가 `Authorization` 헤더에 주입
- **구글 로그인**(웹/PC=GIS, 앱=네이티브 플러그인) — 현재 소셜 로그인 전용(아이디/비번 폼은 `features.js` 플래그로 숨김)
- 401 응답 시 토큰 정리 후 로그인 모달, 라우트 `meta.requiresAuth` / `requiresAdmin` 가드
```sh
npm run build
```
## 주요 기능
- **가계부** — 수입·지출·이체 기록, 검색·필터, 날짜 그룹 접기/펴기, 외화 입력(환율 자동환산)
- **AI 보조** — 자연어 한 줄 입력("어제 스타벅스 5천원"), 영수증 사진 구조화, 분류·계좌 자동 추천, 통계 화면 재무 코멘트·절약 가이드 (Claude Haiku, 집계만 전송·저장은 사용자 확인 후)
- **계좌·자산** — 은행·현금·카드·대출·투자 계좌, 순자산 자동 집계
- **고정 지출** — 반복 규칙 자동 생성 + '확인' 확정 흐름
- **통계** — '이번 달 현황'(예산·수입 대비 통합), 분류별 분석, 순자산 추이, 월말 예상 지출(일회성 큰 지출 제외 결정적 계산), 지난달 대비
- **예산** — 월별 카테고리 예산, 예산 대비 진행률
- **활동 포인트** — 가계부·게시판 활동 적립, 한 달 개근(모든 날 기록) 랜덤 보너스
- **게시판** — 커뮤니티/짠테크/재테크 3개 게시판, 댓글·태그·이미지 업로드·공지
- **테마** — 시스템/라이트/다크 모드
### Lint with [ESLint](https://eslint.org/)
## 문서
| 문서 | 내용 |
|---|---|
| [docs/FRONTEND.md](docs/FRONTEND.md) | 아키텍처·디렉터리 구조·화면 명세 |
| [docs/release-2026-06-06.md](docs/release-2026-06-06.md) | 누적 변경 이력 (릴리스 노트) |
| [docs/ANDROID.md](docs/ANDROID.md) | 안드로이드(Capacitor) 빌드·배포 |
| [docs/plan-membership-tiers.md](docs/plan-membership-tiers.md) | 무료/유료 티어 설계 (미구현) |
| [deploy/README.md](deploy/README.md) | 프론트 배포(Nginx + Gitea Actions) |
| [brand/LAUNCH-PROGRESS.md](brand/LAUNCH-PROGRESS.md) | Play 스토어 출시 진행 상황·로드맵 |
```sh
npm run lint
```
## 배포
- `main` 푸시 → Gitea Actions 가 CI(빌드·테스트) + Deploy(Nginx 업로드·reload) 실행
- **테스트 게이트**: CI·배포 워크플로 모두 테스트 실패 시 배포 중단
- 상세: [deploy/README.md](deploy/README.md)
+2 -2
View File
@@ -15,8 +15,8 @@ android {
applicationId "kr.sblog.slimbudget"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 3
versionName "1.0.2"
versionCode 6
versionName "1.0.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+88 -22
View File
@@ -1,6 +1,6 @@
# 돈돼지 가계부 — Play 스토어 첫 출시 진행 상황
> 최종 업데이트: 2026-06-28. 첫 정식 출시(무료 앱) 준비 로그 + 남은 로드맵.
> 최종 업데이트: 2026-07-11. 첫 정식 출시(무료 앱) 준비 로그 + 남은 로드맵.
> 관련 문서: [play-store-listing.md](play-store-listing.md) · [production-access-draft.md](production-access-draft.md)
---
@@ -33,7 +33,7 @@
- 생성기 `brand/gen-store-assets.mjs` + 마스터 `brand/logo-1024.png`
### 스크린샷 (`brand/screenshots/`, 7컷, 실기기 1080×2340)
- shot0 홈 대시보드(리드) / shot1 가계부 내역 / shot2 통계 / shot3 고정지출 / shot4 계좌 / shot5 게시판 / shot6 게시글 상세
- shot0 홈 대시보드(리드) / shot1 가계부 내역 / shot2 통계 / shot3 정기결제 / shot4 계좌 / shot5 게시판 / shot6 게시글 상세
### 등록 문서
- `brand/play-store-listing.md` — 등록정보(제목·설명)·데이터안전 양식·콘텐츠등급 답안
@@ -52,14 +52,26 @@
---
## 🚧 현재 상태 (2026-06-30)
## 🚧 현재 상태 (2026-07-05)
- ✅ 개발자 계정 신원확인 완료 → 앱 생성 완료(`kr.sblog.slimbudget`)
- ✅ 필수 설정(데이터안전·콘텐츠등급(전체이용가)·앱액세스(둘러보기)·광고없음·비거래자·방침URL) 완료
- ✅ 스토어 등록정보·이미지(아이콘512·피처·스크린샷7) 완료
-**비공개 테스트 트랙에 v2(AAB) 게시 → 검토 통과**
- ✅ 구글 로그인 SHA-1 이슈 해결(Play 앱서명 SHA-1 등록) → 로그인 정상
- ✅ Google Search Console sblog.kr 도메인 소유권 인증 완료 (DNS TXT)
-**테스터 12명 opt-in 대기**(업체 의뢰) → 14일 → 프로덕션 신청
- 📌 조직 계정 전환은 보류(사업자등록 → DUNS 필요. 기프트콘 기능 때 함께)
- **v4 AAB 빌드 완료** (versionCode 4, 1.0.3) → 비공개 트랙 업로드 대기
- ⏳ 개인사업자 계정 전환 → DUNS 신청 완료(D&B 발급 대기) → 발급 후 Play Console에서 마저 진행
- Play Console 웹사이트 인증(www.sblog.kr) 완료
### 🍎 iOS(App Store) 트랙 — 2026-07-11
-**애플 개발자 등록 완료** (Team ID `G9PDM2C4L6`)
-**애플 로그인(Sign in with Apple) 실기기 동작 확인** — App Store 4.8 요건 충족
-**iOS 번들ID `kr.sblog.slimbudget.app`** (기존 `kr.sblog.slimbudget`는 타 계정 선점 → iOS 전용 새 ID, 안드로이드는 그대로)
- App ID + Sign in with Apple/Push capability, `CODE_SIGN_ENTITLEMENTS` 연결, `ITSAppUsesNonExemptEncryption=false`
-**App Store Connect 앱 생성 + v1.0 (build 1) 심사 제출 완료** — 연령 15+, App Privacy(이메일·사용자콘텐츠·사용자ID / 추적없음 / 앱기능), 무료, 콘텐츠권한 없음, 스크린샷(6.5" 1242×2688 iPhone + iPad 13")
- 로그인은 구글/애플 소셜 전용 → 심사 메모에 **둘러보기(데모)** 안내
-**심사 결과 대기** (승인 시 출시 / 거부 시 Resolution Center 대응)
---
@@ -67,7 +79,7 @@
1.**테스터 12명 opt-in** (업체 의뢰) — 14일 카운트 시작 조건
2.**14일 연속** 12명 유지 → **프로덕션 액세스 신청** (자료: production-access-draft.md)
3. **v3 AAB 업로드**이번 세션 변경(외화·월별예산·전체화면모달 등) 반영 (14일 카운트 리셋 안 됨)
3. **v4 AAB 비공개 트랙 업로드**빌드 완료(versionCode 4, 1.0.3), Play Console 업로드 후 검토 제출 (14일 카운트 리셋 안 됨)
4. 승인 → **프로덕션 출시**
### ⚠️ 폐쇄 테스트 의무 (개인 계정 + 2023-11-13 이후 개설)
@@ -96,37 +108,91 @@
**v2 이후 추가 — 웹 배포됨, v3 빌드 대기**
- 앱 잠금 **1시간 유예**(잦은 PIN 입력 해소)
- **외화 입/지출** — 통화+환율 자동조회(open.er-api.com 무료)→원화 환산, 원본 보존. `amount`는 환산 원화라 통계·예산 무변경
- **외화 환율 갱신 시각** — 환율 자동조회 후 '방금 / N분 전 / N시간 전' 레이블(30초 ticker), FxController `updatedAt` 응답 포함
- **월별 예산 + 전월/다음달 복사** — budget에 year/month 추가, 기존 예산은 현재 월로 1회 이관
- 내역추가 **전체화면 모달**(바깥 닫힘 방지) / 분류 **아코디언**(대분류 삼각형) / 계좌 셀렉트 정리(종류별 안내·이름만)
- 구글 로그인 **Play 서명 SHA-1 등록**(Google Cloud Android OAuth 클라 추가)
- **UI 전면 개편 (2026-07-04)** — 모든 입력 모달 전체화면 슬라이드업(AppModal) 적용:
- 공통 `CategoryPicker.vue` 컴포넌트 — 대분류/소분류 아코디언 칩 + 인라인 추가 (가계부·고정지출·예산 공통)
- `AccountView` 계좌종류·구분 셀렉트→뱃지, 분류→CategoryPicker
- `RecurringView` 구분·주기 셀렉트→뱃지, 계좌/카드→뱃지, 분류→CategoryPicker
- `AccountWalletView` 카드종류·상환방식 셀렉트→뱃지
- `BudgetView` 비고정/고정 라디오→뱃지, 카테고리→CategoryPicker
- `CategoryView` 리스트 UI → **칩 그리드 관리 모드** (CategoryPicker admin prop): ✏ 이름수정·✕ 삭제·⠿ 순서변경(토글 후 드래그)
- **게시판 포인트 정책** — 작성 10P(일 5회·50P 한도), 스팸 키워드 포함 시 미지급; 추천 +5P(작성자·일 50P)/비추 -1P; 남글추천 +1P(투표자·일 10P)/취소 -1P
- **가계부 작성 포인트** — 건당 10P, 일 5회(50P) 한도
- **스팸 키워드 자동 블라인드** — 14개 키워드 포함 시 글/댓글 즉시 블라인드, 포인트 미지급
- **관리자 신고글 목록 고도화** — 신고 누적 + 스팸 감지 통합; 🤖 스팸감지 뱃지·blockReason 표시; 스팸 항목 신고 무시 버튼 숨김
**2026-07-09~11 추가 (웹·서버 배포 완료 / iOS는 재빌드 시 반영)**
- **애플 로그인(Sign in with Apple)** — 프런트(`native/appleAuth`·LoginModal)·백엔드(`/auth/apple`, 애플 JWKS 검증·aud=번들ID)·DB(`member.apple_id`)·iOS entitlements 전 구간. iOS 번들ID `kr.sblog.slimbudget.app`로 변경, 서버 `apple-client-id` 정합. 실기기 로그인 확인
- **입력 UI 모달 → 페이지 전환 + 바텀시트** (톤 통일)
- **계좌**(`WalletFormView`)·**정기결제**(`RecurringFormView`) → 별도 라우트 페이지, 생성 시 계좌 종류 선택 복원
- **내역·예산** → 콘텐츠 영역 인라인 페이지(`?entry`/`?budget` 라우트 바인딩, 뒤로가기로 닫힘) — 핵심 로직(AI·OCR·상환 등) 이관 없이 프레젠테이션만 변경(회귀 위험 최소화)
- 선택 항목 **바텀시트**: `BottomSheet`·`SheetSelect`·**`AccountPicker`(종류→계좌 아코디언)** 공통 컴포넌트. 계좌·분류·검색 필터에 적용(분류는 CategoryPicker `hideAdd`)
- **화면 헤더 뒤로가기 버튼 제거** → 하단 네비 뒤로가기로 유도
- **계좌 관리 개편** — 탭 제거→종류별 그룹 목록, 계좌명 클릭 시 **상세 화면**(`AccountWalletDetailView`), 순서변경 잠금 토글, 투자계좌는 투자금만
- **대출·카드할부 상환 조회(유료)** `RepaymentScheduleView`+`utils/repayment.js` — 상환방식별 상각+카드할부(무이자), 회차별 원금·이자·합계·잔액. **대출 월 상환금(loanPayment) 필드**로 은행 조회액과 정확 일치. 이자율 소수점 3자리. 홈 **'이번 달 상환 예정' 카드(유료)**
- **예산 단순화** — '월 예산'만 등록, 고정/비고정·일/주/년·**1회성 예산 제거**
- **레이아웃** — 하단 고정 내비에 가리던 문제 수정(본문 `padding-bottom`)
- **정기결제 등록 자동세팅** — 원리금균등은 저장된 월 상환금 우선
- **연락처 이메일** → `dondwaeji.app@gmail.com` (약관·계정삭제·스토어 문의)
**2026-07-07 추가 (웹·서버 배포 완료)**
- **정기결제 리네이밍** — 전체 화면 '고정지출' → '정기결제' (백업 엑셀 시트명은 기존 백업 복원 호환 위해 유지)
- **정기결제 주기 월/년만** — DAILY·WEEKLY 제거, 요일선택 UI 제거 (`RecurringView`)
- **계좌 관리 화면 개편** — 탭 제거 → **종류별 그룹 목록**, 계좌명 클릭 시 **상세 화면 분리**(`/account/wallets/:id` `AccountWalletDetailView`), 순서변경 **잠금 토글**, 투자계좌는 투자금만 표기·상세 비활성
- **태그관리 순서변경 토글** — 기본 비활성, '순서변경' 버튼 클릭 시 드래그 활성 (`AccountTagView`)
- **대출·카드할부 상환 조회 (유료)** — `RepaymentScheduleView.vue` + `utils/repayment.js`. 대출 상환방식별 상각(원리금균등·원금균등·만기일시) + 카드 할부(무이자) **남은 스케줄**, 회차별 **원금·이자·합계·남은잔액**, 이번 달/남은 총액. 사이드바 유료 메뉴
- **대출 월 상환금(loanPayment) 필드** — 백엔드 컬럼(`wallet.loan_payment`)+DTO/매퍼, 계좌 폼 선택 입력. 원리금균등을 **은행 조회액과 정확히 일치**(이자=잔액×이율, 원금=월상환금−이자), **마지막 회차 잔여 반올림 정산**. 미입력 시 잔액·개월수로 PMT 추정
- **대출 이자율 소수점 3자리** 입력(step 0.001, `loan_rate` DECIMAL(7,4))
- **가계부 대출 상환 자동세팅** — 원리금균등 선택 시 저장된 **월 상환금 우선** 반영(옛 PMT 추정 대체), 마지막 회차 초과상환 방지 (`AccountView`)
- **홈 대시보드 '이번 달 상환 예정' 카드 (유료)** — 현재일 기준 대출·카드할부 상환 예정 합계, **가계부에 상환 기록 있으면 제외**, 모두 기록 시 '상환 완료' 표시
- **AI 교통 절약 팁 제외** — 버스·지하철·대중교통 분류는 정기권 최저비용 사용 중으로 tips에서 제외 (`AiEntryParser`)
**2026-07-05 추가 (웹 배포됨, v3 빌드 대기)**
- **AI 편의 기능** — 자연어 입력·영수증 비전·분류/계좌 추천·재무 코멘트·절약 가이드 (상세는 아래 🤖 섹션)
- **홈 대시보드 오늘 기록 넛지** — '안녕하세요 OO님' → 오늘 기록 상태로 대체(없으면 '지금 기록하기' CTA / 있으면 'N건'·연속 2일+ 'N일 연속 🔥')
- **통계 화면 정리** — '예산 대비'+'수입 대비' 두 패널 → '이번 달 현황' 단일 패널 통합; 예상 지출은 **결정적 계산**(일회성 큰 지출 제외, 매 접속 동일값); AI 코멘트 로딩 인디케이터
- **한 달 개근 보너스 포인트** — 현재 월 모든 날 기록 시 **랜덤 1~100P**(월 1회, 전월 소급 제외, 화면 안내 없음, `MONTH_COMPLETE_yyyyMM`로 중복 차단)
- **iOS 안전영역·클립보드 수정** — 사이드바 상단 상태바 겹침 해결; 모달 열 때 클립보드 자동읽기 제거(iOS '붙여넣기' 토스트 방지). 음성 입력=iOS 키보드 받아쓰기(실기기 확인)
---
## 📌 남은 백로그 (출시 후 업데이트)
1. **앱 업데이트 안내** — 새 버전 알림 + 구글플레이 바로가기
2. **관리자 신고글 목록 페이지**
3. **가계부 작성 포인트** — 건당 10P, 일 50P 제한
4. **게시판 포인트 정책 개편** — 작성 50P(도배 제외)·키워드필터·추천+5/비추-1(일50)·남글추천+1(일10)/취소-1
5. (선택) 첫 실행 온보딩 / Play 인앱 리뷰 / 생체 인증 / 외화 환율 자동 고도화
6. **조직 계정 전환 + 기프트콘 교환** — 사업자등록→DUNS→앱이전, B2B 기프트콘 매입
2. (선택) 첫 실행 온보딩 / Play 인앱 리뷰 / 생체 인증
3. **조직 계정 전환** — 사업자등록→DUNS→앱이전
4. **포인트 상점** (2026-07-10 구현 완료)
- **계좌 슬롯**: 500P/개, 최대 3개, 영구. `extra_wallet_slots` (member 테이블)
- **AI 통계 분석**: 1000P → 10회 이용권. `ai_stat_credits` (member 테이블)
- FREE 회원이 크레딧 없이 AI 코멘트 호출 시 402 → 통계 화면에서 상점 안내
- PREMIUM 회원은 AI 코멘트 무제한 (크레딧 불필요)
### 🤖 AI 기능 (★ 정식 출시 전 적용 희망)
- 전제: **LLM API 키**(Claude Haiku 4.5 권장 — 학습 미사용·톤 우수·저렴 / 또는 Gemini 무료쿼터) + **개인정보처리방침·데이터안전 갱신**(제3자 LLM 추가). 백엔드 프록시·키 서버보관·실패 시 폴백.
- **AI 입력 보조** (편의성, 1순위)
-**자연어 한 줄 입력**"어제 스타벅스 5천원" → 폼 자동 채움(기존 parseText를 LLM 기반으로). 음성=기기STT+①
-**영수증 사진 → AI 구조화**멀티모달 LLM이 총액·날짜·가맹점·분류 추천(기존 Vision OCR 보강/대체)
-**자동 분류 추천** — 가맹점/메모→분류 제안
- 원칙: AI는 폼 prefill만, **사용자 확인 후 저장**
- **AI 재무 코멘트** — 자산·수입·예상지출 기반 한마디("이러다 파산각" 등). 일1회 캐시·집계수치만 전송.
### 🤖 AI 기능 — ✅ 구현·배포 완료 (2026-07-05)
- 백엔드: `AiEntryParser`(Claude Haiku 4.5, raw HTTP `/v1/messages`), 키는 서버 `/opt/sb-backend/.env`(`ANTHROPIC_API_KEY`), 실패/미설정 시 규칙기반·Google Vision OCR 폴백.
- **① 자연어 한 줄 입력** — "어제 스타벅스 5천원" → 폼 자동 채움. 내역 화면 '✨ 빠른 입력' 바 진입점. (음성=iOS 키보드 받아쓰기, 실기기 확인)
-**② 영수증 사진 → AI 구조화** — Claude Vision이 금액·상호·날짜·분류·계좌 추출(`/ocr/receipt-ai`), 실패 시 Google Vision 폴백.
-**③ 분류·계좌 자동 추천** — 사용자 실제 분류·계좌 목록 중에서 선택(할루시네이션 방지 검증).
-**AI 재무 코멘트 + 절약 가이드** — 통계 화면, **집계 수치만** 전송(거래 원문 미전송), 정중체. 예상 지출은 비결정성 때문에 **결정적 계산**(일회성 큰 지출 제외)으로 분리.
-**개인정보처리방침·데이터안전 갱신** — 처리자 Anthropic(Claude) 명시, 전송 데이터 범위·학습 미사용 고지 (terms.js §2·§3, play-store-listing.md 데이터안전).
- 원칙 준수: AI는 폼 prefill·참고 코멘트만, **저장은 사용자 확인 후**.
---
## 🔨 빌드 상태
- **v2** (versionCode 2, 1.0.1): 비공개 테스트 게시·검토통과
- **v3** (versionCode 3): 위 'v2 이후 추가' 반영 — 빌드 후 비공개 트랙 업로드 (테스터에게 최신본 제공, 14일 카운트 리셋 안 됨)
- **v3** (versionCode 3): 위 'v2 이후 추가' + **AI 기능(자연어·영수증·분류/계좌·재무코멘트)** 전부 반영 — 빌드 후 비공개 트랙 업로드 (14일 카운트 리셋 안 됨)
- ⚠️ 빌드 전 서버 `.env``ANTHROPIC_API_KEY`(AI) 확인
- ✅ 가계부 내역 오류 해결됨 (2026-07-05)
- ✅ 1회성 예산(BudgetOnetime) 백엔드 배포 완료 (2026-07-05)
---
## 다음 세션 재개 지점
**테스터 12명 opt-in 진행상황 확인**(업체 의뢰) → 12명 + 14일 연속 충족되면 **프로덕션 액세스 신청**(`production-access-draft.md`).
v3 AAB를 비공개 트랙에 업로드해 테스터가 최신본을 받게.
0.**[진행중] iOS App Store 심사 결과 대기** — v1.0 제출 완료. 승인 시 출시(자동/수동), 거부 시 Resolution Center 대응(주로 데모 접근 안내)
1. **테스터 12명 opt-in 진행상황 확인**(업체 의뢰) → 12명 + 14일 연속 충족되면 **프로덕션 액세스 신청**
2. **v5 AAB 비공개 트랙 업로드** — 이번 작업분(애플로그인·페이지개편·상환조회 등) 반영해 AAB 재빌드·업로드
3. **DUNS 발급 대기** → 발급 시 Play Console 개인사업자 계정 전환
4. **수익화(출시 후)** — 구독(iOS StoreKit 미구현 → 결제코드+ASC상품+영수증검증 필요) / 광고(광고SDK+ATT+App Privacy 갱신). 둘 다 무료 출시 후 업데이트로
5. (선택) **음성 전용 🎤 버튼**`@capacitor-community/speech-recognition`
6. **CI 러너 안정화** — Gitea act_runner 멈춤(재부팅 후 대기)로 배포 지연된 적 있음 → 자동재시작/헬스체크
+12 -3
View File
@@ -57,13 +57,13 @@
오늘부터 돈돼지와 함께 슬림한 가계 관리를 시작해보세요!
문의: sbsb401824@gmail.com
문의: dondwaeji.app@gmail.com
```
### 기타 등록정보 필드
- **앱 카테고리**: 금융 (Finance)
- **태그**: 가계부, 예산, 자산관리
- **연락처 이메일**: sbsb401824@gmail.com
- **연락처 이메일**: dondwaeji.app@gmail.com
- **개인정보처리방침 URL**: https://www.sblog.kr/privacy
- **그래픽 에셋**:
- 앱 아이콘: brand/store/icon-512.png
@@ -78,7 +78,7 @@
### 개요 질문
- 데이터를 수집하나요? → **예**
- 데이터를 제3자와 공유하나요? → **아니요** (광고 끔. OCR처리 위탁이며 별도 공유 아님)
- 데이터를 제3자와 공유하나요? → **아니요** (광고 끔. OCR·AI(Anthropic)는 대행 처리(처리 위탁)이며 Google Play 기준 '공유'에 해당하지 않음)
- 전송 중 데이터가 암호화되나요? → **예** (HTTPS)
- 사용자가 데이터 삭제를 요청할 수 있나요? → **예** (앱 내 '회원 탈퇴' + 이메일 요청, https://www.sblog.kr/account-deletion)
@@ -100,6 +100,15 @@
> ✅ 광고를 껐으므로 **광고 식별자(기기 ID) 수집/공유가 없습니다** — 데이터 안전 양식이 단순해지고 심사 불일치 리스크가 사라졌습니다.
### AI 편의 기능의 데이터 처리 (처리 위탁, 공유 아님)
> 아래는 개인정보처리방침 3항과 정합. 회원이 해당 기능을 쓸 때에만, 기능 수행 목적으로 Anthropic(Claude) API에 전송됨. Anthropic은 학습에 사용하지 않고, 서비스도 별도 저장하지 않음(폼 자동 채움용). API 키는 서버 보관.
> - 자연어 한 줄 입력: 입력 텍스트 → 금액·분류 등 구조화
> - 영수증 사진 구조화: 영수증 이미지 → 금액·상호·날짜·분류
> - 분류·계좌 자동 추천: 분류·계좌 '이름' 목록
> - 재무 코멘트: 이번 달 **집계 수치만**(개별 거래 원문 미전송)
>
> Play 데이터 안전에서 위 데이터 유형(금융 정보·사진)의 **공유 여부는 계속 '아니요'** (대행 처리는 공유 아님). 다만 방침에 처리자(Anthropic)를 명시했으므로 심사자 교차확인에도 일치함.
---
## 3. 콘텐츠 등급 설문 (IARC)
+49 -31
View File
@@ -1,16 +1,23 @@
# Slim Budget — 프론트엔드 (sb_pt)
# 돈돼지 가계부 — 프론트엔드 (sb_pt)
Vue 3 + Vite 기반의 가계부·게시판 웹 애플리케이션 프론트엔드.
Vue 3 + Vite 기반의 가계부·자산·게시판 앱 프론트엔드. 하나의 코드베이스로 **웹 / 안드로이드(Capacitor) / Windows 데스크톱(Electron)** 3개 플랫폼을 빌드합니다.
## 기술 스택
- **Vue 3** (`<script setup>` Composition API) + **Vite**
- **Pinia** (상태관리), **Vue Router** (라우팅)
- **Axios** (HTTP, 공통 인스턴스 + 토큰/401 인터셉터)
- **Toast UI Editor** (`@toast-ui/editor`) + Prism.js (게시판 마크다운 작성/뷰어·코드 하이라이트)
- **Capacitor**(안드로이드 하이브리드 앱) · **Electron**(Windows 데스크톱) · **Vitest**(테스트)
## 브랜딩
- 서비스명 **Slim Budget** (헤더 로고/타이틀/푸터)
- 로고: `public/logo.svg` (브랜드 그린 `#00bc7e` 배지 + 슬림 막대 차트), 파비콘으로도 사용
- 서비스명 **돈돼지 가계부** (구 Slim Budget)
- 로고: 돈돼지(투명 배경) — 헤더/사이드바 로고·파비콘·앱 런처 아이콘. 브랜드 그린 `#00bc7e`
## 플랫폼별 동작
- **웹(PC·모바일)**: 안내 페이지([WebOnlyNotice.vue](../src/components/WebOnlyNotice.vue))만 노출(앱 다운로드 유도). 실제 이용은 앱에서. `npm run dev` 는 예외로 전체 앱 표시
- **안드로이드**: Capacitor WebView 패키징 → [ANDROID.md](ANDROID.md)
- **Windows**: Electron 이 라이브 사이트(app.sblog.kr)를 직접 로드 → 프론트 변경이 재설치 없이 반영
- 기능 플래그 [src/config/features.js](../src/config/features.js): `ID_LOGIN_ENABLED`(아이디 로그인)·`BILLING_ENABLED`(인앱결제)·`ADS_ENABLED`(광고)·`CARD_NOTIF_ENABLED`(카드알림) — 첫 출시 기준 전부 `false`
## 디렉터리 구조
```
@@ -21,65 +28,76 @@ src/
│ ├── boardApi.js # 게시판
│ ├── adminApi.js # 관리자(태그 카테고리)
│ └── accountApi.js # 가계부 전체
│ ├── billingApi.js # 인앱결제/구독
├── stores/ # Pinia (auth, ui, user)
├── config/features.js # 기능 플래그(아이디로그인·결제·광고·카드알림)
├── router/index.js # 라우트 + 인증 가드
├── components/
│ ├── layout/ # AppHeader / AppSidebar / AppFooter
│ ├── ui/IconBtn.vue # 공통 SVG 아이콘 버튼
│ ├── layout/ # AppHeader / AppSidebar / AppFooter / AppBottomNav
│ ├── ui/ # IconBtn / AppModal(전체화면 슬라이드업) / AppDialog / CategoryPicker
│ ├── editor/ # MarkdownEditor / MarkdownViewer
│ ├── LoginModal.vue / SignupModal.vue
│ ├── LoginModal.vue / SignupModal.vue / AppLockScreen.vue(PIN) / AdBanner.vue / WebOnlyNotice.vue
├── views/
│ ├── HomeView.vue # 홈(대시보드 자리, 현재 빈 공간)
│ ├── UsersView.vue # 사용자 관리
│ ├── HomeView.vue # 홈 대시보드(요약·일별 캘린더)
│ ├── board/ # 게시판 (목록/상세/작성)
│ ├── account/ # 가계부 (아래 참조)
│ └── admin/ # 태그 관리(관리자)
└── assets/main.css # 전역 스타일
│ └── admin/ # 회원/태그 관리(관리자)
└── assets/ # base.css(data-theme 다크모드) 등 전역 스타일
```
## 인증
- 로그인 시 발급된 **Bearer 토큰**을 `localStorage`에 저장, `http.js` 요청 인터셉터가 `Authorization` 헤더에 주입
- **구글 로그인**(웹/PC=Google Identity Services, 앱=네이티브 플러그인) — 현재 소셜 로그인 전용(`ID_LOGIN_ENABLED=false` 로 아이디/비번 폼·회원가입 UI 숨김)
- 401 응답 시 토큰 정리 후 `auth:unauthorized` 이벤트 → 로그인 모달 표시
- 라우트 `meta.requiresAuth` / `requiresAdmin` 가드
## 레이아웃 / 반응형
- 그리드 레이아웃(헤더·사이드바·본문·푸터)
## 레이아웃 / 반응형 / 테마
- 그리드 레이아웃 — 사이드바가 좌측 상단(헤더 행)까지, 헤더는 현재 화면 타이틀 표시. 본문 폭 `--page-max`(760px)로 통일
- **하단 고정 내비**([AppBottomNav.vue](../src/components/layout/AppBottomNav.vue)): 뒤로·앞으로·홈·새로고침·설정 (안드로이드 소프트키 위 안전영역)
- **모바일(≤768px)**: 사이드바가 **오프캔버스 드로어**(햄버거 토글 + 백드롭, 라우트 이동 시 자동 닫힘)
- 모든 액션 버튼은 **아이콘화**(`IconBtn`: 등록/수정/삭제/검색/페이징 등), `title`로 접근성·툴팁 제공
- 한 줄에 담기 어려운 목록은 **2줄 표기**(게시판: 제목/메타, 가계부 내역: 분류·금액/날짜·메모·액션 등)
- **입력 모달**: 전체화면 슬라이드업([AppModal.vue](../src/components/ui/AppModal.vue), 바깥 닫힘 방지). 셀렉트 대신 **뱃지/칩** 선택, 분류는 공통 [CategoryPicker.vue](../src/components/ui/CategoryPicker.vue)(대/소분류 아코디언)
- **테마**: 시스템 / 라이트 / 다크 (설정 화면, `data-theme` 기반)
- 모든 액션 버튼은 **아이콘화**(`IconBtn`), `title`로 접근성·툴팁 제공
## 게시판 (`/board`)
- 목록(페이징·태그 필터·검색[제목/본문/댓글]), 상세, 작성/수정(마크다운)
- 댓글, 태그, 관리자 **열람 제한/해제**
- **커뮤니티 / 짠테크 수다방 / 재테크 팁** 3개 게시판(카테고리)
- 목록(페이징·태그 필터·검색[제목/본문/댓글]·공지 최상단), 상세, 작성/수정(마크다운·이미지 업로드)
- 댓글, 추천(vote), 태그, **공지**(관리자), 관리자 **열람 제한/해제**, 신고
## 가계부 (`/account`)
사용자별 데이터 격리(본인 데이터만). 주요 화면(사이드바 하위 메뉴):
| 경로 | 화면 | 설명 |
|------|------|------|
| `/account` | 대시보드 | 예산 대비 지출 도넛, **분류별 파이차트**, 기간별 예산대비지출 막대(툴팁), 수입대비지출, 월별 총액, **순자산 추이** 라인차트 |
| `/account/entries` | 가계부 내역 | 월별 내역 CRUD, **검색·필터**(키워드/구분/분류/계좌/태그), 분류 인라인 추가 |
| `/account/recurrings` | 정기 거래 | 일/주/월/년 반복 규칙, 진입 시 자동 반영(중복 없이) |
| `/account/wallets` | 계좌 관리 | 은행/카드/대출/**투자** 계좌, 잔액·순자산, 상환/납부, **투자 포트폴리오** |
| `/account/categories` | 분류 관리 | 수입/지출 분류 CRUD, 이름 변경 전파, 기존 내역에서 가져오기 |
| `/account/budget` | 예산 설정 | 고정(일수 기준 자동 환산)/비고정, 예산 대비 진행률 |
| `/account` | 대시보드 | 예산 대비 지출 도넛, **분류별 파이차트**(대분류 집계), 수입대비지출(월말 예상·지난달 대비), 월별 총액, **순자산 추이**(주간) 라인차트, 일별 캘린더 |
| `/account/entries` | 가계부 내역 | 월별 내역 CRUD, **검색·필터**(키워드/구분/분류/계좌/태그), 날짜 그룹 접기/펴기, **외화 입력**(환율 자동환산), 내역→고정지출 바로 등록 |
| `/account/recurrings` | 고정 지출 | 일/주/월/년 반복 규칙, 진입 시 자동 반영(중복 없이·'확인 필요'로 생성) |
| `/account/wallets` | 계좌 관리 | 은행/**현금**/카드/대출/**투자** 계좌, 잔액·순자산, 상환/납부(원금·이자·연회비 분리), 월별 이용내역 |
| `/account/categories` | 분류 관리 | 수입/지출 분류 CRUD(**대/소분류 2단계**), 칩 그리드 관리, 순서변경, 기존 내역에서 가져오기 |
| `/account/budget` | 예산 설정 | **월별 예산**(전월/다음달 복사), 고정(일수 기준 자동 환산)/비고정, 예산 대비 진행률 |
| `/account/tags` | 태그 관리 | 사용자별 가계부 태그 |
### 잔액·순자산 모델 (표시)
- 자산(+) / 부채(−) 부호 모델. 지갑 잔액 = 개시잔액 + 수입 − 지출 − 이체출금 + 이체입금
- 자산(+) / 부채(−) 부호 모델. 현금은 은행과 동일하게 자산 합산. 지갑 잔액 = 개시잔액 + 수입 − 지출 − 이체출금 + 이체입금
- 순자산 = 총자산 − 총부채 (투자계좌는 평가금액 반영)
### 투자 포트폴리오 (계좌 관리 > 투자 탭)
### 투자 계좌 (계좌 관리 > 투자 탭)
- 투자계좌를 펼치면 [InvestPortfolio.vue](../src/views/account/InvestPortfolio.vue) 패널
- **종목별** 보유: 수량·평균매입단가·현재가(수동)·평가금액·**평가손익·수익률**·실현손익
- **매수/매도** 건별 기록(이동평균 평단 자동 산출, 매도수량 초과 검증)
- 입금은 가계부 **이체**(은행→투자)로, 예수금·주식평가는 자동 계산되어 순자산·추이에 반영
- 원화·정수 수량 기준 (해외주식 소수점/환율·자동 시세는 범위 외)
- **단순형 UI**: **투자금(투입원금) + 현재 평가액**만 입력 → 손익·수익률 자동 계산(퇴직연금·연금 등 포함). 계좌 펼침 시 투자금/평가액/손익(%) 요약 카드
- 입금은 가계부 **이체**(은행→투자)로, 평가액은 순자산·추이에 반영
- ℹ️ 백엔드는 종목 단위(매수·매도·시세 자동조회) API 를 유지하나 현재 UI 에는 노출하지 않음. 원화 기준
## 설정 화면
- 하단 내비 → 설정: 계정정보(이름·이메일·비밀번호 변경) · 테마(시스템/라이트/다크) · 앱 잠금(PIN, 1시간 유예) · 앱 버전 · App Data 삭제 · (PC)창 크기 프리셋
## 개발/빌드
```bash
npm install
npm run dev # 개발 서버
npm run build # dist/ 정적 빌드
npm run dev # 개발 서버(전체 앱 표시)
npm run build # dist/ 정적 빌드(웹 배포)
npm run cap:sync # 앱 빌드 + 안드로이드 동기화
npm run electron:build # Windows 설치파일
npm test # Vitest
```
- API 베이스 URL: `VITE_API_BASE_URL` (기본 `/api`)
- 상세: [../README.md](../README.md) · 앱 [ANDROID.md](ANDROID.md) · 배포 [../deploy/README.md](../deploy/README.md)
+81
View File
@@ -0,0 +1,81 @@
# iOS 하이브리드 앱 (Capacitor)
Vue `dist` 를 네이티브 WebView로 패키징해 iOS 앱으로 빌드합니다. 안드로이드와 **동일 코드베이스**이며, 플랫폼 분기는 `Capacitor.isNativePlatform()` / `getPlatform() === 'ios'` 로 처리합니다.
## 구성
- `capacitor.config.json` — appId `kr.sblog.slimbudget`(안드와 동일), appName `돈돼지 가계부`, webDir `dist`
- `ios/` — 네이티브 프로젝트(커밋됨, 빌드 산출물 `ios/App/build`·`Pods` 는 .gitignore)
- **Capacitor 8 = Swift Package Manager(SPM)** 사용 (CocoaPods 아님 — Podfile 불필요)
- 웹 게이팅: iOS 는 `isNativePlatform()` 이 true → 안내 페이지가 아닌 전체 앱 표시
## 사전 준비 (Mac)
- **Xcode**(App Store, 대용량) + iOS SDK/시뮬레이터 런타임
```bash
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
sudo xcodebuild -license accept
xcodebuild -downloadPlatform iOS # 시뮬레이터 런타임(별도 온디맨드 다운로드)
```
- **Apple Developer Program($99/년)** — 실기기 배포·App Store·Apple 로그인 Capability 에 필요. (시뮬레이터 UI 테스트는 불필요)
## 최초 셋업 (1회)
```bash
npm i @capacitor/ios
npx cap add ios # ios/ 생성
npm run build:app && npx cap sync ios
npx capacitor-assets generate --ios # 브랜드 아이콘 생성(assets/ 기준)
```
## 빌드 & 실행
```bash
npm run build:app && npx cap sync ios # 코드 변경 시마다
npx cap open ios # Xcode 열기
# 또는 CLI 로 시뮬레이터에 바로:
npx cap run ios --target <시뮬레이터-UDID>
```
시뮬레이터 목록: `xcrun simctl list devices available | grep iPhone`
### 실기기 실행 (Xcode)
1. **데이터 지원 케이블**로 아이폰 연결 → "이 컴퓨터 신뢰" (충전 전용 케이블은 인식 안 됨)
2. Xcode → **App 타겟 → Signing & Capabilities → Automatically manage signing → Team 선택**(무료 Apple ID 도 7일 한정 가능)
3. 상단 툴바 중앙 **기기 선택**(스킴 `App` 옆) → 연결된 아이폰 → **▶ Run**
4. 최초엔 아이폰이 **개발자 모드**를 요구 → Run 시도 후 **설정 → 개인정보 보호 및 보안 → 개발자 모드 → 켬 → 재시동**
5. "신뢰할 수 없는 개발자" → **설정 → 일반 → VPN 및 기기 관리 → 개발자 앱 → 신뢰**
## 필수 설정 (반영 완료)
- **번들 ID** `kr.sblog.slimbudget` (안드로이드와 동일 — App Store Connect 에서 그대로 사용)
- **Info.plist 권한 문구** (없으면 카메라/사진 접근 시 크래시):
- `NSCameraUsageDescription`(영수증·프로필·게시판 촬영)
- `NSPhotoLibraryUsageDescription` / `NSPhotoLibraryAddUsageDescription`
- **safe-area**: `index.html` 의 `viewport-fit=cover` + CSS `env(safe-area-inset-*)`.
⚠️ 전역 `box-sizing: border-box` 에서 고정 `height` 를 쓰면 safe-area 패딩이 높이에 포함돼 콘텐츠가 눌린다 → 헤더는 `min-height`+세로패딩, 하단 내비는 `height: calc(56px + env(safe-area-inset-bottom))` 로 처리(적용됨).
## Apple 로그인 (Sign in with Apple)
> App Store 심사 가이드라인 **4.8**: 제3자 소셜 로그인(구글)을 제공하면 **Apple 로그인도 동등하게 제공**해야 함. iOS 출시에 **사실상 필수**.
**구현 상태(코드 완료):**
- 프론트: `@capacitor-community/apple-sign-in` + [LoginModal.vue](../src/components/LoginModal.vue) Apple 버튼(iOS 전용) + [appleAuth.js](../src/native/appleAuth.js)
- ⚠️ 이 플러그인은 Capacitor 7 기준(`capacitor-swift-pm 7.x`) → **patch-package** 로 8.x 로 패치(`patches/`). `npm ci` 후 `postinstall` 이 자동 적용. @capawesome 구글 플러그인과 iOS 공존.
- 백엔드: `POST /api/auth/apple` — **nimbus-jose-jwt 로 Apple JWKS(공개키) 서명 검증** + iss/aud(번들ID)/exp 확인, `member.apple_id` 로 구글과 동일하게 이메일 자동연결. `app.apple-client-id`(기본 `kr.sblog.slimbudget`).
- `ios/App/App/App.entitlements` — `applesignin` 엔틀먼트 준비됨.
**작동시키려면(유료 승인 후):**
1. Apple Developer 포털: App ID `kr.sblog.slimbudget` 에 **Sign in with Apple 활성화**
2. Xcode → Signing & Capabilities → **+ Capability → "Sign in with Apple"** (준비된 `App.entitlements` 연결 + 프로비저닝)
3. 테스트: 시뮬레이터 **설정 → Apple ID 로그인** 후 `cap run ios` → Apple 버튼
- Sign in with Apple 은 **시뮬레이터에서도 작동**(카메라/푸시와 달리). 단 1~2 가 갖춰져야 함.
## 배포 (App Store)
- **의무 테스트 기간 없음** — 안드로이드(Play)의 "테스터 12명·14일"과 달리, iOS 는 **바로 심사 제출 가능**. TestFlight 은 **선택**.
- 절차: App Store Connect 앱 생성 → 아이콘·스크린샷(6.x" iPhone 규격)·App Privacy 작성 → Xcode Archive → 업로드 → 심사(보통 1~3일)
- **TestFlight**(선택): 유료 승인 후 빌드 업로드 → **케이블 없이 아이폰에 무선 설치**(데이터 케이블 없을 때 유용). 내부 100명 즉시 / 외부 10,000명(가벼운 베타 심사).
## 자주 막히는 부분
- **SPM 의존성 충돌**: 플러그인이 서로 다른 `capacitor-swift-pm`/`googlesignin-ios` 버전을 요구하면 빌드 실패. Capacitor 8 전용 플러그인을 쓰거나(patch-package) 버전 정렬 필요.
- **USB 연결이 충전만 됨**: 충전 전용 케이블 → **데이터 케이블**로 교체. 무선 디버깅도 최초 1회 유선 페어링 필요.
- **개발자 모드 메뉴가 없음**: Xcode 로 기기 설치를 **한 번 시도한 뒤** 설정에 항목이 생김(iOS 16+).
- **Developer 등록 "보류"**: 개인 등록은 Apple 이 추가 본인확인을 요청할 수 있음 → **Apple ID 이메일 확인 후 회신**해야 진행됨(자동으로 안 풀림).
- **카메라/푸시는 시뮬레이터 제한** → 실기기 필요. Apple 로그인은 시뮬레이터 가능.
## 참고
- 안드로이드 빌드: [ANDROID.md](ANDROID.md) · 아키텍처: [FRONTEND.md](FRONTEND.md)
- 앱 전용 백엔드 URL: `.env.capacitor` 의 `VITE_API_BASE_URL`(HTTPS 필수)
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>돈돼지 가계부</title>
</head>
<body>
+13
View File
@@ -0,0 +1,13 @@
App/build
App/Pods
App/output
App/App/public
DerivedData
xcuserdata
# Cordova plugins for Capacitor
capacitor-cordova-ios-plugins
# Generated Config files
App/App/capacitor.config.json
App/App/config.xml
+393
View File
@@ -0,0 +1,393 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 60;
objects = {
/* Begin PBXBuildFile section */
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
327E84D03001463F00AB0467 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
504EC2FB1FED79650016851F = {
isa = PBXGroup;
children = (
958DCC722DB07C7200EA8C5F /* debug.xcconfig */,
504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */,
);
sourceTree = "<group>";
};
504EC3051FED79650016851F /* Products */ = {
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
);
name = Products;
sourceTree = "<group>";
};
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
327E84D03001463F00AB0467 /* App.entitlements */,
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
504EC3131FED79650016851F /* Info.plist */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
);
path = App;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
504EC3031FED79650016851F /* App */ = {
isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
buildPhases = (
504EC3001FED79650016851F /* Sources */,
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = App;
packageProductDependencies = (
4D22ABE82AF431CB00220026 /* CapApp-SPM */,
);
productName = App;
productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 2660;
TargetAttributes = {
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
compatibilityVersion = "Xcode 8.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 504EC2FB1FED79650016851F;
packageReferences = (
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */,
);
productRefGroup = 504EC3051FED79650016851F /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
504EC3031FED79650016851F /* App */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
504EC3021FED79650016851F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
50B271D11FEDC1A000F3C39B /* public in Resources */,
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
504EC3001FED79650016851F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
504EC30C1FED79650016851F /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
504EC3101FED79650016851F /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
504EC3111FED79650016851F /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
504EC3141FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = G9PDM2C4L6;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
504EC3151FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = G9PDM2C4L6;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
504EC3171FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = kr.sblog.slimbudget.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
504EC3181FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = kr.sblog.slimbudget.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3141FED79650016851F /* Debug */,
504EC3151FED79650016851F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3171FED79650016851F /* Debug */,
504EC3181FED79650016851F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = "CapApp-SPM";
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
4D22ABE82AF431CB00220026 /* CapApp-SPM */ = {
isa = XCSwiftPackageProductDependency;
package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */;
productName = "CapApp-SPM";
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 504EC2FC1FED79650016851F /* Project object */;
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,105 @@
{
"originHash" : "275f5197fc45ecbed402538938c9f019518bacb33270bc32cf5549af9b2891e6",
"pins" : [
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1",
"version" : "11.3.0"
}
},
{
"identity" : "appauth-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/openid/AppAuth-iOS.git",
"state" : {
"revision" : "2781038865a80e2c425a1da12cc1327bcd56501f",
"version" : "1.7.6"
}
},
{
"identity" : "capacitor-swift-pm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ionic-team/capacitor-swift-pm.git",
"state" : {
"revision" : "2231987d85b8b0b289320b1d0947b4ae8345cde4",
"version" : "8.4.1"
}
},
{
"identity" : "googlesignin-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleSignIn-iOS",
"state" : {
"revision" : "65fb3f1aa6ffbfdc79c4e22178a55cd91561f5e9",
"version" : "8.0.0"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "9f183ae842be978784f2963a343682e0c46d8fb3",
"version" : "8.1.2"
}
},
{
"identity" : "gtm-session-fetcher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b",
"version" : "3.5.0"
}
},
{
"identity" : "gtmappauth",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GTMAppAuth.git",
"state" : {
"revision" : "5d7d66f647400952b1758b230e019b07c0b4b22a",
"version" : "4.1.1"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
}
},
{
"identity" : "ion-ios-camera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ionic-team/ion-ios-camera.git",
"state" : {
"revision" : "2cd4f06b0ece21b6458e23357280c51f94bfceb8",
"version" : "1.0.4"
}
},
{
"identity" : "ion-ios-filesystem",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ionic-team/ion-ios-filesystem.git",
"state" : {
"revision" : "0d81e26e828ff9582807e2339112cedf2e0fab85",
"version" : "1.1.2"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
"version" : "2.4.1"
}
}
],
"version" : 3
}
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
</dict>
</plist>
+49
View File
@@ -0,0 +1,49 @@
import UIKit
import Capacitor
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
return true
}
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
}
func applicationDidEnterBackground(_ application: UIApplication) {
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
}
func applicationWillEnterForeground(_ application: UIApplication) {
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
}
func applicationWillTerminate(_ application: UIApplication) {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
// Called when the app was launched with a url. Feel free to add additional processing here,
// but if you want the App API to support tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
// Called when the app was launched with an activity, including Universal Links.
// Feel free to add additional processing here, but if you want the App API to support
// tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 507 KiB

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

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

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