From 80e1faec09a06615319a33eadf287331b297afbe Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Sun, 28 Jun 2026 00:52:02 +0900 Subject: [PATCH 1/3] =?UTF-8?q?docs:=20=EB=A9=A4=EB=B2=84=EC=8B=AD(?= =?UTF-8?q?=EB=AC=B4=EB=A3=8C/=EC=9C=A0=EB=A3=8C)=20=EB=A9=94=EB=89=B4?= =?UTF-8?q?=C2=B7=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=EB=B6=84=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20(=EC=B6=94=ED=9B=84=20=EA=B5=AC=ED=98=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 계좌 개수 제한 없음, 핵심 기록 무료 - 유료: OCR·카드알림 자동인식·통계·예산·고정지출·투자·태그·백업 Co-Authored-By: Claude Opus 4.8 --- docs/plan-membership-tiers.md | 51 +++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/plan-membership-tiers.md diff --git a/docs/plan-membership-tiers.md b/docs/plan-membership-tiers.md new file mode 100644 index 0000000..a7da1ba --- /dev/null +++ b/docs/plan-membership-tiers.md @@ -0,0 +1,51 @@ +# 멤버십(무료/유료) 메뉴·기능 구분 — 설계 (추후 구현) + +> 상태: **설계만** (미구현). 결제·게이팅은 추후 작업. +> 방향: **핵심 기록은 무료로 개방**(진입장벽 ↓), **과금 기능·자동화·고급 분석·무제한·백업은 유료**. +> 계좌 개수 제한은 **두지 않음**(1~소수 제한은 핵심 기능을 막아 거부감 → 제외). + +--- + +## 1. 무료 / 유료 구분 + +| 메뉴 / 기능 | 무료 | 유료(프리미엄) | 비고 | +|---|:--:|:--:|---| +| **가계부 내역**(수입·지출 기록) | ✅ | ✅ | 핵심 — 무료 개방 | +| **계좌 관리**(은행·현금·카드·대출) | ✅ **무제한** | ✅ 무제한 | 개수 제한 없음 | +| 자주 쓰는 내역(빠른 등록) | ✅ | ✅ | 편의, 무료 | +| 월별 요약(수입/지출/잔액) | ✅ | ✅ | 기본 요약은 무료 | +| 분류 관리 | ✅ 기본+소수 | ✅ 무제한·소분류(2단계) | 무료는 기본 분류 위주 | +| 태그 관리 | — | ✅ | | +| **통계**(순자산 추이·예상 지출·지난달 대비·분류 분석) | — | ✅ | 고급 분석은 유료 | +| 예산 설정 | — | ✅ | | +| 고정 지출(자동 생성·확인) | — | ✅ | 자동화 = 유료 | +| **영수증 OCR** | — | ✅ | **Google Vision 과금** → 유료 필수 | +| **카드 결제 알림 자동인식** | — | ✅ | 강력한 유료 훅(편의·자동화) | +| 투자/자산(평가액) 관리 | — | ✅ | | +| 데이터 백업/복구(엑셀·JSON) | — | ✅ | 추후 백업 기능과 연계 | +| 게시판(커뮤니티/짠테크/재테크 팁) | ✅ | ✅ | 커뮤니티는 전체 개방 | +| 다크모드·테마 | ✅ | ✅ | 기본 제공 | +| 광고 | (도입 시) 표시 | 제거 | 광고 모델은 미정 | + +## 2. 구분 기준 +- **무료** = 기본 기록 + (무제한)계좌 + 기본 분류 + 월 요약 → "충분히 써볼 만한" 수준 +- **유료** = ① 과금 기능(OCR) · ② 자동화(카드 알림 인식 · 고정지출) · ③ 고급 분석(통계 전체 · 예산) · ④ 무제한(태그/소분류) · ⑤ 백업/복구 +- 원칙: **무료를 일부러 불편하게 만들지 않고, 유료에 "확실히 편한 것"을 몰아준다**(전환율 우선). + +## 3. 구현 시 필요한 것 (추후) +- **데이터**: `member.plan`(`FREE`/`PREMIUM`) + `plan_expires_at`(만료일) 컬럼. +- **게이팅(이중)**: + - 프론트: 유료 메뉴에 **잠금 배지 + 업그레이드 유도**(데모의 잠금 화면 재사용 가능), 비유료는 진입 차단. + - 백엔드: 해당 API에 **plan 권한 체크**(프론트 우회 방지). OCR·알림 인식·통계·예산·고정지출·투자·백업 엔드포인트. +- **결제 연동**: 안드로이드 인앱결제(Google Play Billing) / 웹·PC는 PG. 정기결제 시 만료일 갱신. +- **기존 데이터 보호**: 유료→무료 강등(만료) 시 데이터는 보존하되 **유료 메뉴만 잠금**(읽기 정도는 허용 검토). + +## 4. 추후 결정 필요(미정) +- 가격·주기(월/연), 무료체험 기간. +- 광고 모델 도입 여부(무료=광고 / 유료=제거). +- 무료 "기본 분류" 범위(개수·기본 템플릿만 허용 여부). +- 강등 시 잠금 정책(완전 차단 vs 읽기 허용). + +--- + +*참고: 데모(`/demo`)의 잠금 안내 화면을 유료 게이팅 UI 의 기반으로 재사용할 수 있음.* From 4ffc01f4848879933bbb646616a0d74a8dabe6ad Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Sun, 28 Jun 2026 07:49:37 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EB=91=98=EB=9F=AC=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0(=EB=8D=B0=EB=AA=A8)=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=E2=80=94=20?= =?UTF-8?q?=EB=8D=B0=EB=AA=A8=20=EB=AA=A8=EB=93=9C=20+=20API=20=EB=8D=94?= =?UTF-8?q?=EB=AF=B8=20=EC=9D=91=EB=8B=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/demo: demo.on 플래그 + DTO 형태 더미데이터 + mock API(읽기 더미/쓰기 차단) - accountApi: Proxy 로 데모 시 읽기→더미, 쓰기→안내. 실제 뷰 그대로 렌더 - App: authed=인증||데모 → 실제 사이드바/셸 표시 + 데모 배너(로그인/나가기) - 라우터: 데모 허용(가계부/고정/계좌/분류) 외 보호화면은 DemoLockedView(로그인 유도) - 사이드바: 데모 시 잠금(🔒) 배지, 메뉴는 showMenu(인증||데모) - 로그인/로그아웃 시 데모 자동 해제, 랜딩 '둘러보기'→enterDemo - 기존 독립 DemoView 제거 Co-Authored-By: Claude Opus 4.8 --- src/App.vue | 61 +++- src/api/accountApi.js | 38 ++- src/components/layout/AppHeader.vue | 2 +- src/components/layout/AppSidebar.vue | 15 +- src/demo/index.js | 129 ++++++++ src/router/index.js | 21 +- src/stores/auth.js | 4 + src/views/DemoLockedView.vue | 66 ++++ src/views/DemoView.vue | 470 --------------------------- src/views/HomeView.vue | 8 +- 10 files changed, 331 insertions(+), 483 deletions(-) create mode 100644 src/demo/index.js create mode 100644 src/views/DemoLockedView.vue delete mode 100644 src/views/DemoView.vue diff --git a/src/App.vue b/src/App.vue index c06bfe2..1d910db 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,6 +1,6 @@ + + + + diff --git a/src/views/DemoView.vue b/src/views/DemoView.vue deleted file mode 100644 index a658850..0000000 --- a/src/views/DemoView.vue +++ /dev/null @@ -1,470 +0,0 @@ - - - - - diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 4128414..974501b 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -5,6 +5,12 @@ import { useAuthStore } from '@/stores/auth' import { useUiStore } from '@/stores/ui' import { accountApi } from '@/api/accountApi' import { ID_LOGIN_ENABLED } from '@/config/features' +import { enterDemo } from '@/demo' + +function startDemo() { + enterDemo() + router.push('/account/entries') +} const auth = useAuthStore() const ui = useUiStore() @@ -299,7 +305,7 @@ onMounted(load)
- +

{{ !ID_LOGIN_ENABLED || ui.signupEnabled ? '로그인하면 나의 가계부 요약을 볼 수 있어요.' : '현재 회원가입이 제한되어 있습니다.' }}

From 457fd8127a6fb7f1bbe710849757c24ba35ff13f Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Sun, 28 Jun 2026 08:14:19 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=EC=84=A4=EC=A0=95=20-=20=EC=97=91?= =?UTF-8?q?=EC=85=80=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=82=B4=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EA=B8=B0(=EB=B0=B1=EC=97=85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - utils/backup.js: 가계부 전체(내역·계좌·분류·고정지출·예산·태그·자주내역)를 이름 기준 참조로 .xlsx 다중 시트 생성. xlsx 동적 import(별도 청크) - 웹/PC=다운로드, 앱(네이티브)=Filesystem 저장 + Share - SettingsView: '데이터 백업' 카드(엑셀로 내보내기 동작 / 가져오기 버튼=준비중) Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 135 +++++++++++++++++++++++++++- package.json | 5 +- src/demo/index.js | 31 +++++-- src/utils/backup.js | 84 +++++++++++++++++ src/views/HomeView.vue | 7 +- src/views/settings/SettingsView.vue | 35 ++++++++ 6 files changed, 289 insertions(+), 8 deletions(-) create mode 100644 src/utils/backup.js diff --git a/package-lock.json b/package-lock.json index f6fe29d..f472ed5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,9 @@ "@capacitor/camera": "^8.2.0", "@capacitor/cli": "^8.3.4", "@capacitor/core": "^8.3.4", + "@capacitor/filesystem": "^8.1.2", "@capacitor/preferences": "^8.0.1", + "@capacitor/share": "^8.0.1", "@capawesome/capacitor-google-sign-in": "^0.1.2", "@toast-ui/editor": "^3.2.2", "@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0", @@ -22,7 +24,8 @@ "prismjs": "^1.30.0", "sortablejs": "^1.15.7", "vue": "^3.5.32", - "vue-router": "^5.0.4" + "vue-router": "^5.0.4", + "xlsx": "^0.18.5" }, "devDependencies": { "@capacitor/assets": "^3.0.5", @@ -1187,6 +1190,18 @@ "tslib": "^2.1.0" } }, + "node_modules/@capacitor/filesystem": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-8.1.2.tgz", + "integrity": "sha512-doaaMfGoFR2hWU6aV6u83I+5ZsGyJVq+Gz4r9lMpJzUKMm1eMu0hLnFdV1aXZlU9FlK/RndFrVD8oRZfNOqWgQ==", + "license": "MIT", + "dependencies": { + "@capacitor/synapse": "^1.0.4" + }, + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/preferences": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/@capacitor/preferences/-/preferences-8.0.1.tgz", @@ -1196,6 +1211,21 @@ "@capacitor/core": ">=8.0.0" } }, + "node_modules/@capacitor/share": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/share/-/share-8.0.1.tgz", + "integrity": "sha512-3cSBKBCJVon54rKDROP2rqGyeGks4pBh9TbaEk9S375Kbek/ZHe72N50zIa0Vn9Eac/SuhwgehO/mmA4CsUOiw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/synapse": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz", + "integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==", + "license": "ISC" + }, "node_modules/@capawesome/capacitor-google-sign-in": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@capawesome/capacitor-google-sign-in/-/capacitor-google-sign-in-0.1.2.tgz", @@ -4015,6 +4045,15 @@ "dev": true, "license": "MIT" }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -5182,6 +5221,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -5378,6 +5430,15 @@ "node": ">=4" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -5782,6 +5843,18 @@ "dev": true, "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -7479,6 +7552,15 @@ "node": ">= 6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -12540,6 +12622,18 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/ssri": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", @@ -14175,6 +14269,24 @@ "node": ">=8" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -14297,6 +14409,27 @@ "node": ">=10.0.0" } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xml-js": { "version": "1.6.11", "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", diff --git a/package.json b/package.json index 1bb4dbc..4eb8998 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,9 @@ "@capacitor/camera": "^8.2.0", "@capacitor/cli": "^8.3.4", "@capacitor/core": "^8.3.4", + "@capacitor/filesystem": "^8.1.2", "@capacitor/preferences": "^8.0.1", + "@capacitor/share": "^8.0.1", "@capawesome/capacitor-google-sign-in": "^0.1.2", "@toast-ui/editor": "^3.2.2", "@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0", @@ -55,7 +57,8 @@ "prismjs": "^1.30.0", "sortablejs": "^1.15.7", "vue": "^3.5.32", - "vue-router": "^5.0.4" + "vue-router": "^5.0.4", + "xlsx": "^0.18.5" }, "devDependencies": { "@capacitor/assets": "^3.0.5", diff --git a/src/demo/index.js b/src/demo/index.js index b55c9b0..62071f8 100644 --- a/src/demo/index.js +++ b/src/demo/index.js @@ -94,15 +94,36 @@ const TAGS = [ { id: 403, name: '경조사' }, ] -// 요약은 내역에서 계산해 일관성 보장 -const SUMMARY = (() => { +// 해당 연·월 내역만 (월 이동 반영) +function inMonth(e, year, month) { + if (!year || !month) return true + return e.entryDate.startsWith(`${year}-${String(month).padStart(2, '0')}`) +} +// 검색·필터 적용 +function filterEntries(p = {}) { + let rows = ENTRIES.filter((e) => inMonth(e, p.year, p.month)) + if (p.type) rows = rows.filter((e) => e.type === p.type) + if (p.category) rows = rows.filter((e) => e.category === p.category) + if (p.walletId) rows = rows.filter((e) => String(e.walletId) === String(p.walletId)) + if (p.keyword) { + const k = String(p.keyword).trim() + rows = rows.filter((e) => (e.memo || '').includes(k) || (e.category || '').includes(k)) + } + if (p.tagId) { + const t = TAGS.find((x) => String(x.id) === String(p.tagId)) + if (t) rows = rows.filter((e) => (e.tags || []).includes(t.name)) + } + return rows +} +function summaryOf(p = {}) { let income = 0, expense = 0 for (const e of ENTRIES) { + if (!inMonth(e, p.year, p.month)) continue if (e.type === 'INCOME') income += e.amount else if (e.type === 'EXPENSE') expense += e.amount } return { totalIncome: income, totalExpense: expense, balance: income - expense } -})() +} const NETWORTH = { totalAssets: 15860000, totalLiabilities: 340000, netWorth: 15520000 } function ok(data) { @@ -115,8 +136,8 @@ function block() { // 읽기 응답 / 쓰기 차단 export const demoApi = { - list: () => ok(ENTRIES), - summary: () => ok(SUMMARY), + list: (params) => ok(filterEntries(params)), + summary: (params) => ok(summaryOf(params)), wallets: () => ok(WALLETS), netWorth: () => ok(NETWORTH), categories: () => ok(CATEGORIES), diff --git a/src/utils/backup.js b/src/utils/backup.js new file mode 100644 index 0000000..aaf0665 --- /dev/null +++ b/src/utils/backup.js @@ -0,0 +1,84 @@ +// 데이터 백업 — 가계부 전체 데이터를 엑셀(.xlsx)로 내보내기. +// 참조는 이름 기준(계좌명·분류명·태그명)으로 저장해 추후 '가져오기(복구)' 시 재매핑이 단순하도록 한다. +import { Capacitor } from '@capacitor/core' +import { accountApi } from '@/api/accountApi' + +async function safe(promise) { + try { + return await promise + } catch { + return [] + } +} + +function fileName() { + const d = new Date() + const p = (n) => String(n).padStart(2, '0') + return `돈돼지가계부_백업_${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}.xlsx` +} + +// 워크북을 플랫폼에 맞게 저장: 웹/PC=다운로드, 앱(네이티브)=파일 저장 후 공유 +async function saveWorkbook(XLSX, wb, name) { + if (Capacitor.isNativePlatform()) { + const base64 = XLSX.write(wb, { type: 'base64', bookType: 'xlsx' }) + const { Filesystem, Directory } = await import('@capacitor/filesystem') + const { Share } = await import('@capacitor/share') + const res = await Filesystem.writeFile({ path: name, data: base64, directory: Directory.Cache }) + try { + await Share.share({ title: name, text: '가계부 백업 파일', url: res.uri }) + } catch { + /* 공유 취소 — 파일은 캐시에 저장됨 */ + } + } else { + XLSX.writeFile(wb, name) + } +} + +export async function exportBackup() { + const XLSX = await import('xlsx') + + const [entries, wallets, categories, recurrings, budgets, tags, quick] = await Promise.all([ + safe(accountApi.list({})), + safe(accountApi.wallets()), + safe(accountApi.categories()), + safe(accountApi.recurrings()), + safe(accountApi.budgets()), + safe(accountApi.tags()), + safe(accountApi.quickEntries()), + ]) + + const catNameById = {} + categories.forEach((c) => (catNameById[c.id] = c.name)) + + const wb = XLSX.utils.book_new() + const add = (name, rows) => XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(rows), name) + + add('내역', entries.map((e) => ({ + 날짜: e.entryDate, 구분: e.type, 분류: e.category, 금액: e.amount, 메모: e.memo, + 계좌: e.walletName, 입금계좌: e.toWalletName, 할부개월: e.installmentMonths, + 태그: (e.tags || []).join(','), + }))) + add('계좌', wallets.map((w) => ({ + 종류: w.type, 이름: w.name, '은행/카드사': w.issuer, 계좌번호: w.accountNumber, + 카드유형: w.cardType, 개시잔액: w.openingBalance, 개시일: w.openingDate, '평가액(수동)': w.currentValue, + }))) + add('분류', categories.map((c) => ({ + 구분: c.type, 이름: c.name, 대분류: c.parentId ? catNameById[c.parentId] || '' : '', + }))) + add('고정지출', recurrings.map((r) => ({ + 제목: r.title, 구분: r.type, 금액: r.amount, 분류: r.category, 메모: r.memo, + 계좌: r.walletName, 입금계좌: r.toWalletName, 주기: r.frequency, + 일: r.dayOfMonth, 요일: r.dayOfWeek, 월: r.monthOfYear, + 시작일: r.startDate, 종료일: r.endDate, 활성: r.active, + }))) + add('예산', budgets.map((b) => ({ + 분류: b.category, 고정: b.fixed, 기준단위: b.baseUnit, 기준금액: b.baseAmount, + 일: b.daily, 주: b.weekly, 월: b.monthly, 년: b.yearly, + }))) + add('태그', tags.map((t) => ({ 이름: t.name }))) + add('자주쓰는내역', quick.map((q) => ({ + 라벨: q.label, 구분: q.type, 분류: q.category, 금액: q.amount, 메모: q.memo, 계좌: q.walletName, + }))) + + await saveWorkbook(XLSX, wb, fileName()) +} diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 974501b..9da5c55 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -700,10 +700,15 @@ onMounted(load) } .cta { display: flex; + flex-direction: column; + align-items: center; gap: 0.6rem; - justify-content: center; margin-top: 1.4rem; } +.cta .btn { + width: 100%; + max-width: 260px; +} .btn { padding: 0.6rem 1.4rem; border: 1px solid var(--color-border); diff --git a/src/views/settings/SettingsView.vue b/src/views/settings/SettingsView.vue index ac1625e..176ff3f 100644 --- a/src/views/settings/SettingsView.vue +++ b/src/views/settings/SettingsView.vue @@ -4,9 +4,26 @@ import { RouterLink } from 'vue-router' import { Preferences } from '@capacitor/preferences' import { useDialog } from '@/composables/dialog' import { useUiStore } from '@/stores/ui' +import { exportBackup } from '@/utils/backup' const dialog = useDialog() const ui = useUiStore() + +const exporting = ref(false) +async function doExport() { + if (exporting.value) return + exporting.value = true + try { + await exportBackup() + } catch (e) { + window.alert(e?.message || '내보내기에 실패했습니다.') + } finally { + exporting.value = false + } +} +function importBackup() { + dialog.alert('가져오기(복구)는 준비 중입니다. 곧 제공됩니다.') +} const THEMES = [ { value: 'system', label: '시스템' }, { value: 'light', label: '라이트' }, @@ -92,6 +109,24 @@ async function clearAppData() { + +
+ + +
+