Merge branch 'dev'
CI / build (push) Successful in 37s
Deploy / deploy (push) Failing after 10m45s

This commit is contained in:
ByungCheol
2026-06-28 08:17:41 +09:00
15 changed files with 666 additions and 486 deletions
+51
View File
@@ -0,0 +1,51 @@
# 멤버십(무료/유료) 메뉴·기능 구분 — 설계 (추후 구현)
> 상태: **설계만** (미구현). 결제·게이팅은 추후 작업.
> 방향: **핵심 기록은 무료로 개방**(진입장벽 ↓), **과금 기능·자동화·고급 분석·무제한·백업은 유료**.
> 계좌 개수 제한은 **두지 않음**(1~소수 제한은 핵심 기능을 막아 거부감 → 제외).
---
## 1. 무료 / 유료 구분
| 메뉴 / 기능 | 무료 | 유료(프리미엄) | 비고 |
|---|:--:|:--:|---|
| **가계부 내역**(수입·지출 기록) | ✅ | ✅ | 핵심 — 무료 개방 |
| **계좌 관리**(은행·현금·카드·대출) | ✅ **무제한** | ✅ 무제한 | 개수 제한 없음 |
| 자주 쓰는 내역(빠른 등록) | ✅ | ✅ | 편의, 무료 |
| 월별 요약(수입/지출/잔액) | ✅ | ✅ | 기본 요약은 무료 |
| 분류 관리 | ✅ 기본+소수 | ✅ 무제한·소분류(2단계) | 무료는 기본 분류 위주 |
| 태그 관리 | — | ✅ | |
| **통계**(순자산 추이·예상 지출·지난달 대비·분류 분석) | — | ✅ | 고급 분석은 유료 |
| 예산 설정 | — | ✅ | |
| 고정 지출(자동 생성·확인) | — | ✅ | 자동화 = 유료 |
| **영수증 OCR** | — | ✅ | **Google Vision 과금** → 유료 필수 |
| **카드 결제 알림 자동인식** | — | ✅ | 강력한 유료 훅(편의·자동화) |
| 투자/자산(평가액) 관리 | — | ✅ | |
| 데이터 백업/복구(엑셀·JSON) | — | ✅ | 추후 백업 기능과 연계 |
| 게시판(커뮤니티/짠테크/재테크 팁) | ✅ | ✅ | 커뮤니티는 전체 개방 |
| 다크모드·테마 | ✅ | ✅ | 기본 제공 |
| 광고 | (도입 시) 표시 | 제거 | 광고 모델은 미정 |
## 2. 구분 기준
- **무료** = 기본 기록 + (무제한)계좌 + 기본 분류 + 월 요약 → "충분히 써볼 만한" 수준
- **유료** = ① 과금 기능(OCR) · ② 자동화(카드 알림 인식 · 고정지출) · ③ 고급 분석(통계 전체 · 예산) · ④ 무제한(태그/소분류) · ⑤ 백업/복구
- 원칙: **무료를 일부러 불편하게 만들지 않고, 유료에 "확실히 편한 것"을 몰아준다**(전환율 우선).
## 3. 구현 시 필요한 것 (추후)
- **데이터**: `member.plan`(`FREE`/`PREMIUM`) + `plan_expires_at`(만료일) 컬럼.
- **게이팅(이중)**:
- 프론트: 유료 메뉴에 **잠금 배지 + 업그레이드 유도**(데모의 잠금 화면 재사용 가능), 비유료는 진입 차단.
- 백엔드: 해당 API에 **plan 권한 체크**(프론트 우회 방지). OCR·알림 인식·통계·예산·고정지출·투자·백업 엔드포인트.
- **결제 연동**: 안드로이드 인앱결제(Google Play Billing) / 웹·PC는 PG. 정기결제 시 만료일 갱신.
- **기존 데이터 보호**: 유료→무료 강등(만료) 시 데이터는 보존하되 **유료 메뉴만 잠금**(읽기 정도는 허용 검토).
## 4. 추후 결정 필요(미정)
- 가격·주기(월/연), 무료체험 기간.
- 광고 모델 도입 여부(무료=광고 / 유료=제거).
- 무료 "기본 분류" 범위(개수·기본 템플릿만 허용 여부).
- 강등 시 잠금 정책(완전 차단 vs 읽기 허용).
---
*참고: 데모(`/demo`)의 잠금 안내 화면을 유료 게이팅 UI 의 기반으로 재사용할 수 있음.*
+134 -1
View File
@@ -13,7 +13,9 @@
"@capacitor/camera": "^8.2.0", "@capacitor/camera": "^8.2.0",
"@capacitor/cli": "^8.3.4", "@capacitor/cli": "^8.3.4",
"@capacitor/core": "^8.3.4", "@capacitor/core": "^8.3.4",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/preferences": "^8.0.1", "@capacitor/preferences": "^8.0.1",
"@capacitor/share": "^8.0.1",
"@capawesome/capacitor-google-sign-in": "^0.1.2", "@capawesome/capacitor-google-sign-in": "^0.1.2",
"@toast-ui/editor": "^3.2.2", "@toast-ui/editor": "^3.2.2",
"@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0", "@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0",
@@ -22,7 +24,8 @@
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^5.0.4" "vue-router": "^5.0.4",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",
@@ -1187,6 +1190,18 @@
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
}, },
"node_modules/@capacitor/filesystem": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-8.1.2.tgz",
"integrity": "sha512-doaaMfGoFR2hWU6aV6u83I+5ZsGyJVq+Gz4r9lMpJzUKMm1eMu0hLnFdV1aXZlU9FlK/RndFrVD8oRZfNOqWgQ==",
"license": "MIT",
"dependencies": {
"@capacitor/synapse": "^1.0.4"
},
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/preferences": { "node_modules/@capacitor/preferences": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/preferences/-/preferences-8.0.1.tgz", "resolved": "https://registry.npmjs.org/@capacitor/preferences/-/preferences-8.0.1.tgz",
@@ -1196,6 +1211,21 @@
"@capacitor/core": ">=8.0.0" "@capacitor/core": ">=8.0.0"
} }
}, },
"node_modules/@capacitor/share": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/share/-/share-8.0.1.tgz",
"integrity": "sha512-3cSBKBCJVon54rKDROP2rqGyeGks4pBh9TbaEk9S375Kbek/ZHe72N50zIa0Vn9Eac/SuhwgehO/mmA4CsUOiw==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/synapse": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
"integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
"license": "ISC"
},
"node_modules/@capawesome/capacitor-google-sign-in": { "node_modules/@capawesome/capacitor-google-sign-in": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/@capawesome/capacitor-google-sign-in/-/capacitor-google-sign-in-0.1.2.tgz", "resolved": "https://registry.npmjs.org/@capawesome/capacitor-google-sign-in/-/capacitor-google-sign-in-0.1.2.tgz",
@@ -4015,6 +4045,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -5182,6 +5221,19 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chai": { "node_modules/chai": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -5378,6 +5430,15 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color": { "node_modules/color": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ -5782,6 +5843,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/create-require": { "node_modules/create-require": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@@ -7479,6 +7552,15 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fs-constants": { "node_modules/fs-constants": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -12540,6 +12622,18 @@
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"optional": true "optional": true
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/ssri": { "node_modules/ssri": {
"version": "12.0.0", "version": "12.0.0",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz",
@@ -14175,6 +14269,24 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -14297,6 +14409,27 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml-js": { "node_modules/xml-js": {
"version": "1.6.11", "version": "1.6.11",
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
+4 -1
View File
@@ -46,7 +46,9 @@
"@capacitor/camera": "^8.2.0", "@capacitor/camera": "^8.2.0",
"@capacitor/cli": "^8.3.4", "@capacitor/cli": "^8.3.4",
"@capacitor/core": "^8.3.4", "@capacitor/core": "^8.3.4",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/preferences": "^8.0.1", "@capacitor/preferences": "^8.0.1",
"@capacitor/share": "^8.0.1",
"@capawesome/capacitor-google-sign-in": "^0.1.2", "@capawesome/capacitor-google-sign-in": "^0.1.2",
"@toast-ui/editor": "^3.2.2", "@toast-ui/editor": "^3.2.2",
"@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0", "@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0",
@@ -55,7 +57,8 @@
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^5.0.4" "vue-router": "^5.0.4",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",
+56 -5
View File
@@ -1,6 +1,6 @@
<script setup> <script setup>
import { onMounted, onUnmounted, watch } from 'vue' import { computed, onMounted, onUnmounted, watch } from 'vue'
import { RouterView, useRoute } from 'vue-router' import { RouterView, useRoute, useRouter } from 'vue-router'
import AppHeader from '@/components/layout/AppHeader.vue' import AppHeader from '@/components/layout/AppHeader.vue'
import AppSidebar from '@/components/layout/AppSidebar.vue' import AppSidebar from '@/components/layout/AppSidebar.vue'
import AppBottomNav from '@/components/layout/AppBottomNav.vue' import AppBottomNav from '@/components/layout/AppBottomNav.vue'
@@ -11,10 +11,20 @@ import AppDialog from '@/components/ui/AppDialog.vue'
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui' import { useUiStore } from '@/stores/ui'
import { demo, exitDemo } from '@/demo'
const auth = useAuthStore() const auth = useAuthStore()
const ui = useUiStore() const ui = useUiStore()
const route = useRoute() const route = useRoute()
const router = useRouter()
// 로그인 또는 둘러보기(데모) 상태면 사이드바/전체 셸 표시
const authed = computed(() => auth.isAuthenticated || demo.on)
function leaveDemo() {
exitDemo()
router.push('/')
}
// 앱(Capacitor 네이티브) 또는 데스크톱(Electron) 클라이언트에서 전체 이용. // 앱(Capacitor 네이티브) 또는 데스크톱(Electron) 클라이언트에서 전체 이용.
// 웹(브라우저)은 안내 페이지만 노출. 개발 모드(npm run dev)는 예외로 전체 앱 표시. // 웹(브라우저)은 안내 페이지만 노출. 개발 모드(npm run dev)는 예외로 전체 앱 표시.
@@ -43,14 +53,21 @@ watch(() => route.fullPath, () => ui.closeSidebar())
<!-- (Capacitor): 전체 기능 --> <!-- (Capacitor): 전체 기능 -->
<template v-else> <template v-else>
<div class="layout" :class="{ 'sidebar-open': ui.sidebarOpen, 'no-sidebar': !auth.isAuthenticated }"> <div class="layout" :class="{ 'sidebar-open': ui.sidebarOpen, 'no-sidebar': !authed }">
<AppHeader class="layout-top" /> <AppHeader class="layout-top" />
<!-- 로그인 전에는 메뉴가 없어 사이드바를 숨김( 제거·중앙 정렬) --> <!-- 로그인/둘러보기 전에는 메뉴가 없어 사이드바를 숨김( 제거·중앙 정렬) -->
<template v-if="auth.isAuthenticated"> <template v-if="authed">
<AppSidebar class="layout-left" /> <AppSidebar class="layout-left" />
<div class="sidebar-backdrop" @click="ui.closeSidebar()"></div> <div class="sidebar-backdrop" @click="ui.closeSidebar()"></div>
</template> </template>
<main class="layout-body"> <main class="layout-body">
<div v-if="demo.on" class="demo-bar">
<span>👀 <b>둘러보기 모드</b> · 샘플 데이터입니다.</span>
<span class="demo-actions">
<button type="button" class="db-login" @click="ui.openLogin('/account')">로그인</button>
<button type="button" class="db-exit" @click="leaveDemo">나가기</button>
</span>
</div>
<RouterView /> <RouterView />
</main> </main>
<AppBottomNav class="layout-bottom" /> <AppBottomNav class="layout-bottom" />
@@ -84,6 +101,40 @@ watch(() => route.fullPath, () => ui.closeSidebar())
grid-area: left; grid-area: left;
} }
.demo-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1rem;
padding: 0.55rem 0.9rem;
border: 1px solid hsla(160, 100%, 37%, 0.4);
border-radius: 8px;
background: hsla(160, 100%, 37%, 0.08);
font-size: 0.88rem;
}
.demo-actions {
display: flex;
gap: 0.4rem;
}
.demo-bar button {
padding: 0.35rem 0.8rem;
border-radius: 6px;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
}
.db-login {
border: 1px solid hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 1);
color: #fff;
}
.db-exit {
border: 1px solid var(--color-border);
background: var(--color-background);
color: var(--color-text);
}
.layout-body { .layout-body {
grid-area: body; grid-area: body;
padding: 1.5rem 2rem; padding: 1.5rem 2rem;
+37 -1
View File
@@ -1,7 +1,8 @@
import http from './http' import http from './http'
import { demo, demoApi } from '@/demo'
// 백엔드 /api/account 엔드포인트와 매핑 (본인 데이터만) // 백엔드 /api/account 엔드포인트와 매핑 (본인 데이터만)
export const accountApi = { const realApi = {
list({ year, month, type, category, walletId, keyword, tagId } = {}) { list({ year, month, type, category, walletId, keyword, tagId } = {}) {
return http.get('/account/entries', { params: { year, month, type, category, walletId, keyword, tagId } }) return http.get('/account/entries', { params: { year, month, type, category, walletId, keyword, tagId } })
}, },
@@ -198,3 +199,38 @@ export const accountApi = {
}) })
}, },
} }
// ===== 둘러보기(데모) 모드 =====
// demo.on 이면 읽기는 더미 데이터, 쓰기는 차단(안내 메시지)으로 응답한다.
const DEMO_READS = {
list: demoApi.list,
summary: demoApi.summary,
wallets: demoApi.wallets,
netWorth: demoApi.netWorth,
categories: demoApi.categories,
recurrings: demoApi.recurrings,
quickEntries: demoApi.quickEntries,
pendingCount: demoApi.pendingCount,
tags: demoApi.tags,
walletEntries: demoApi.walletEntries,
}
const DEMO_WRITES = new Set([
'create', 'update', 'remove', 'parseText', 'createQuickEntry', 'removeQuickEntry', 'repayment',
'createRecurring', 'updateRecurring', 'removeRecurring', 'runRecurrings',
'createWallet', 'updateWallet', 'removeWallet', 'reorderWallets',
'createCategory', 'updateCategory', 'removeCategory', 'reorderCategories', 'importCategories',
'createTag', 'updateTag', 'removeTag', 'reorderTags', 'confirmEntry', 'ocrReceipt',
'createBudget', 'updateBudget', 'removeBudget', 'setExpectedIncome',
'createHolding', 'updateHolding', 'removeHolding', 'addTrade', 'updateTrade', 'removeTrade',
'refreshPrices', 'refreshAllPrices',
])
export const accountApi = new Proxy(realApi, {
get(target, prop) {
if (demo.on) {
if (DEMO_READS[prop]) return DEMO_READS[prop]
if (DEMO_WRITES.has(prop)) return () => demoApi.block()
}
return target[prop]
},
})
+1 -1
View File
@@ -14,7 +14,7 @@ const route = useRoute()
// 라우트별 헤더 타이틀(현재 화면 이름) // 라우트별 헤더 타이틀(현재 화면 이름)
const TITLES = { const TITLES = {
home: '돈돼지 가계부', home: '돈돼지 가계부',
demo: '둘러보기', 'demo-locked': '둘러보기',
'account-entries': '가계부 내역', 'account-entries': '가계부 내역',
'account-stats': '통계', 'account-stats': '통계',
'account-recurrings': '고정 지출', 'account-recurrings': '고정 지출',
+14 -1
View File
@@ -1,11 +1,15 @@
<script setup> <script setup>
import { computed } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui' import { useUiStore } from '@/stores/ui'
import { BOARDS } from '@/constants/boards' import { BOARDS } from '@/constants/boards'
import { demo } from '@/demo'
const auth = useAuthStore() const auth = useAuthStore()
const ui = useUiStore() const ui = useUiStore()
// 로그인 또는 둘러보기(데모)면 메뉴 노출. 데모에선 잠금 배지를 함께 표시.
const showMenu = computed(() => auth.isAuthenticated || demo.on)
// 메뉴 아이콘 (lucide 스타일 인라인 SVG path — 하단 내비와 동일 톤). 값은 정적/신뢰 마크업. // 메뉴 아이콘 (lucide 스타일 인라인 SVG path — 하단 내비와 동일 톤). 값은 정적/신뢰 마크업.
const icons = { const icons = {
@@ -33,7 +37,7 @@ const icons = {
</div> </div>
<nav class="menu"> <nav class="menu">
<!-- 가계부 영역 (홈은 하단 내비게이션으로 이동) --> <!-- 가계부 영역 (홈은 하단 내비게이션으로 이동) -->
<template v-if="auth.isAuthenticated"> <template v-if="showMenu">
<RouterLink to="/account/entries" class="menu-item"> <RouterLink to="/account/entries" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.entries" /> <svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.entries" />
<span>가계부 내역</span> <span>가계부 내역</span>
@@ -41,6 +45,7 @@ const icons = {
<RouterLink to="/account/stats" class="menu-item"> <RouterLink to="/account/stats" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.stats" /> <svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.stats" />
<span>통계</span> <span>통계</span>
<span v-if="demo.on" class="lock-badge">🔒</span>
</RouterLink> </RouterLink>
<RouterLink to="/account/recurrings" class="menu-item"> <RouterLink to="/account/recurrings" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.recurrings" /> <svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.recurrings" />
@@ -57,10 +62,12 @@ const icons = {
<RouterLink to="/account/budget" class="menu-item"> <RouterLink to="/account/budget" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.budget" /> <svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.budget" />
<span>예산 설정</span> <span>예산 설정</span>
<span v-if="demo.on" class="lock-badge">🔒</span>
</RouterLink> </RouterLink>
<RouterLink to="/account/tags" class="menu-item"> <RouterLink to="/account/tags" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.tags" /> <svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.tags" />
<span>태그 관리</span> <span>태그 관리</span>
<span v-if="demo.on" class="lock-badge">🔒</span>
</RouterLink> </RouterLink>
<!-- 게시판 영역 --> <!-- 게시판 영역 -->
@@ -73,6 +80,7 @@ const icons = {
> >
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.board" /> <svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.board" />
<span>{{ b.label }}</span> <span>{{ b.label }}</span>
<span v-if="demo.on" class="lock-badge">🔒</span>
</RouterLink> </RouterLink>
</template> </template>
@@ -169,6 +177,11 @@ const icons = {
flex: none; flex: none;
opacity: 0.8; opacity: 0.8;
} }
.lock-badge {
margin-left: auto;
font-size: 0.72rem;
opacity: 0.55;
}
.menu-item.router-link-exact-active .menu-icon { .menu-item.router-link-exact-active .menu-icon {
opacity: 1; opacity: 1;
} }
+150
View File
@@ -0,0 +1,150 @@
// 둘러보기(데모) 모드 — 실제 화면 컴포넌트를 그대로 쓰되 API 만 더미로 응답한다.
// demo.on 이 true 면 accountApi 의 읽기는 아래 더미를, 쓰기는 block()(안내 메시지)으로 응답.
import { reactive } from 'vue'
export const demo = reactive({ on: false })
export function enterDemo() {
demo.on = true
}
export function exitDemo() {
demo.on = false
}
// ===== 더미 데이터 (백엔드 응답 DTO 형태와 동일) =====
const WALLETS = [
{ id: 1, type: 'BANK', name: '신한 주거래', issuer: '신한', accountNumber: null, openingBalance: 0, balance: 2540000, manualValuation: false },
{ id: 2, type: 'BANK', name: '주택청약', issuer: '국민', accountNumber: null, openingBalance: 0, balance: 5000000, manualValuation: false },
{ id: 3, type: 'CASH', name: '현금', openingBalance: 0, balance: 120000, manualValuation: false },
{ id: 4, type: 'CARD', name: '삼성카드', issuer: '삼성', cardType: 'CREDIT', openingBalance: 0, balance: -340000, manualValuation: false },
{
id: 5, type: 'INVEST', name: '연금저축', manualValuation: true,
investedAmount: 7000000, currentValue: 8200000, balance: 8200000,
valuationGain: 1200000, deposit: 0, stockValue: 8200000,
},
]
const CATEGORIES = [
// 지출 대분류/소분류
{ id: 10, type: 'EXPENSE', name: '식비', parentId: null },
{ id: 11, type: 'EXPENSE', name: '점심', parentId: 10 },
{ id: 12, type: 'EXPENSE', name: '저녁', parentId: 10 },
{ id: 13, type: 'EXPENSE', name: '카페/간식', parentId: 10 },
{ id: 20, type: 'EXPENSE', name: '교통', parentId: null },
{ id: 21, type: 'EXPENSE', name: '대중교통', parentId: 20 },
{ id: 22, type: 'EXPENSE', name: '택시', parentId: 20 },
{ id: 30, type: 'EXPENSE', name: '주거/통신', parentId: null },
{ id: 31, type: 'EXPENSE', name: '월세', parentId: 30 },
{ id: 32, type: 'EXPENSE', name: '통신비', parentId: 30 },
{ id: 40, type: 'EXPENSE', name: '문화/여가', parentId: null },
{ id: 41, type: 'EXPENSE', name: 'OTT/구독', parentId: 40 },
{ id: 50, type: 'EXPENSE', name: '쇼핑', parentId: null },
// 수입
{ id: 60, type: 'INCOME', name: '급여', parentId: null },
{ id: 61, type: 'INCOME', name: '용돈', parentId: null },
]
// 이번 달(현 시점) 기준 샘플 내역
function ymd(day) {
const now = new Date()
const m = String(now.getMonth() + 1).padStart(2, '0')
return `${now.getFullYear()}-${m}-${String(day).padStart(2, '0')}`
}
function E(id, day, type, category, amount, memo, walletId, walletName, opt = {}) {
return {
id, entryDate: ymd(day), type, category, amount, memo,
walletId, walletName, toWalletId: null, toWalletName: null,
installmentMonths: null, pending: !!opt.pending, tags: opt.tags || [],
}
}
const ENTRIES = [
E(101, 27, 'EXPENSE', '점심', 9000, '김밥천국', 4, '삼성카드'),
E(102, 27, 'EXPENSE', '카페/간식', 5300, '스타벅스', 4, '삼성카드'),
E(103, 27, 'EXPENSE', '대중교통', 2800, '지하철', 3, '현금'),
E(104, 27, 'EXPENSE', '쇼핑', 25200, '다이소', 4, '삼성카드', { pending: true }),
E(105, 25, 'INCOME', '급여', 4600000, '6월 급여', 1, '신한 주거래'),
E(106, 25, 'EXPENSE', '통신비', 45300, '휴대폰 요금', 1, '신한 주거래'),
E(107, 23, 'EXPENSE', 'OTT/구독', 17000, '넷플릭스', 4, '삼성카드', { tags: ['구독'] }),
E(108, 23, 'EXPENSE', '저녁', 23000, '배달', 4, '삼성카드'),
E(109, 20, 'EXPENSE', '쇼핑', 285000, '마트 장보기', 4, '삼성카드'),
E(110, 18, 'EXPENSE', '저녁', 62000, '친구 외식', 4, '삼성카드', { tags: ['데이트'] }),
E(111, 15, 'EXPENSE', '택시', 28000, '심야 택시', 4, '삼성카드'),
E(112, 12, 'EXPENSE', '쇼핑', 320000, '의류 구매', 4, '삼성카드'),
E(113, 8, 'EXPENSE', '대중교통', 55000, '교통카드 충전', 3, '현금'),
E(114, 5, 'EXPENSE', '카페/간식', 18500, '베이커리', 4, '삼성카드'),
E(115, 1, 'EXPENSE', '월세', 600000, '6월 월세', 1, '신한 주거래'),
]
const RECURRINGS = [
{ id: 201, title: '월세', type: 'EXPENSE', amount: 600000, category: '월세', memo: null, walletId: 1, walletName: '신한 주거래', toWalletId: null, toWalletName: null, frequency: 'MONTHLY', dayOfMonth: 1, dayOfWeek: null, monthOfYear: null, startDate: ymd(1), endDate: null, lastRunDate: null, nextDate: ymd(1), active: true },
{ id: 202, title: '통신비', type: 'EXPENSE', amount: 45300, category: '통신비', memo: null, walletId: 1, walletName: '신한 주거래', toWalletId: null, toWalletName: null, frequency: 'MONTHLY', dayOfMonth: 15, dayOfWeek: null, monthOfYear: null, startDate: ymd(15), endDate: null, lastRunDate: null, nextDate: ymd(15), active: true },
{ id: 203, title: '넷플릭스', type: 'EXPENSE', amount: 17000, category: 'OTT/구독', memo: null, walletId: 4, walletName: '삼성카드', toWalletId: null, toWalletName: null, frequency: 'MONTHLY', dayOfMonth: 23, dayOfWeek: null, monthOfYear: null, startDate: ymd(23), endDate: null, lastRunDate: null, nextDate: ymd(23), active: true },
{ id: 204, title: '급여', type: 'INCOME', amount: 4600000, category: '급여', memo: null, walletId: 1, walletName: '신한 주거래', toWalletId: null, toWalletName: null, frequency: 'MONTHLY', dayOfMonth: 25, dayOfWeek: null, monthOfYear: null, startDate: ymd(25), endDate: null, lastRunDate: null, nextDate: ymd(25), active: true },
]
const QUICK = [
{ id: 301, label: '점심', type: 'EXPENSE', category: '점심', amount: 9000, memo: '점심', walletId: 4, walletName: '삼성카드' },
{ id: 302, label: '커피', type: 'EXPENSE', category: '카페/간식', amount: 5300, memo: '커피', walletId: 4, walletName: '삼성카드' },
{ id: 303, label: '대중교통', type: 'EXPENSE', category: '대중교통', amount: 2800, memo: '교통', walletId: 3, walletName: '현금' },
]
const TAGS = [
{ id: 401, name: '구독' },
{ id: 402, name: '데이트' },
{ id: 403, name: '경조사' },
]
// 해당 연·월 내역만 (월 이동 반영)
function inMonth(e, year, month) {
if (!year || !month) return true
return e.entryDate.startsWith(`${year}-${String(month).padStart(2, '0')}`)
}
// 검색·필터 적용
function filterEntries(p = {}) {
let rows = ENTRIES.filter((e) => inMonth(e, p.year, p.month))
if (p.type) rows = rows.filter((e) => e.type === p.type)
if (p.category) rows = rows.filter((e) => e.category === p.category)
if (p.walletId) rows = rows.filter((e) => String(e.walletId) === String(p.walletId))
if (p.keyword) {
const k = String(p.keyword).trim()
rows = rows.filter((e) => (e.memo || '').includes(k) || (e.category || '').includes(k))
}
if (p.tagId) {
const t = TAGS.find((x) => String(x.id) === String(p.tagId))
if (t) rows = rows.filter((e) => (e.tags || []).includes(t.name))
}
return rows
}
function summaryOf(p = {}) {
let income = 0, expense = 0
for (const e of ENTRIES) {
if (!inMonth(e, p.year, p.month)) continue
if (e.type === 'INCOME') income += e.amount
else if (e.type === 'EXPENSE') expense += e.amount
}
return { totalIncome: income, totalExpense: expense, balance: income - expense }
}
const NETWORTH = { totalAssets: 15860000, totalLiabilities: 340000, netWorth: 15520000 }
function ok(data) {
// 깊은 복제로 화면에서 수정해도 원본 더미는 보존
return Promise.resolve(JSON.parse(JSON.stringify(data)))
}
function block() {
return Promise.reject({ response: { data: { message: '둘러보기 모드에서는 저장되지 않습니다. 로그인 후 이용해 주세요.' } } })
}
// 읽기 응답 / 쓰기 차단
export const demoApi = {
list: (params) => ok(filterEntries(params)),
summary: (params) => ok(summaryOf(params)),
wallets: () => ok(WALLETS),
netWorth: () => ok(NETWORTH),
categories: () => ok(CATEGORIES),
recurrings: () => ok(RECURRINGS),
quickEntries: () => ok(QUICK),
pendingCount: () => ok({ count: 1 }),
tags: () => ok(TAGS),
walletEntries: () => ok([]),
block,
}
+17 -4
View File
@@ -2,6 +2,10 @@ import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router
import HomeView from '../views/HomeView.vue' import HomeView from '../views/HomeView.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui' import { useUiStore } from '@/stores/ui'
import { demo } from '@/demo'
// 둘러보기(데모)에서 더미로 진입 가능한 화면
const DEMO_ALLOWED = new Set(['account-entries', 'account-recurrings', 'account-wallets', 'account-categories'])
// Electron(데스크톱)은 file:// 로 로드 → 해시 히스토리라야 새로고침/딥링크가 안전 // Electron(데스크톱)은 file:// 로 로드 → 해시 히스토리라야 새로고침/딥링크가 안전
const isDesktop = typeof navigator !== 'undefined' && /electron/i.test(navigator.userAgent) const isDesktop = typeof navigator !== 'undefined' && /electron/i.test(navigator.userAgent)
@@ -15,10 +19,10 @@ const router = createRouter({
component: HomeView, component: HomeView,
}, },
{ {
// 로그인 없이 둘러보기 (더미 데이터) — 공개 // 둘러보기(데모)에서 미지원(로그인 필요) 화면 진입 시 — 잠금 안내 (공개)
path: '/demo', path: '/demo-locked',
name: 'demo', name: 'demo-locked',
component: () => import('../views/DemoView.vue'), component: () => import('../views/DemoLockedView.vue'),
}, },
{ {
path: '/users', path: '/users',
@@ -138,6 +142,15 @@ const router = createRouter({
// 전역 네비게이션 가드 // 전역 네비게이션 가드
router.beforeEach((to, from) => { router.beforeEach((to, from) => {
const auth = useAuthStore() const auth = useAuthStore()
// 둘러보기(데모): 지원 화면은 더미로 진입, 그 외 보호 화면은 잠금 안내로
if (demo.on && !auth.isAuthenticated) {
if (to.name === 'home') return { name: 'account-entries' }
if (to.meta.requiresAuth) {
if (DEMO_ALLOWED.has(to.name)) return true
return { name: 'demo-locked', query: { from: to.name || '' } }
}
return true // 공개 라우트(잠금 안내 등)
}
// 인증이 필요한 페이지인데 미로그인 → 로그인 팝업 오픈 (원래 목적지 보존), 이동은 취소/홈 // 인증이 필요한 페이지인데 미로그인 → 로그인 팝업 오픈 (원래 목적지 보존), 이동은 취소/홈
if (to.meta.requiresAuth && !auth.isAuthenticated) { if (to.meta.requiresAuth && !auth.isAuthenticated) {
const ui = useUiStore() const ui = useUiStore()
+4
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { Preferences } from '@capacitor/preferences' import { Preferences } from '@capacitor/preferences'
import { authApi } from '@/api/authApi' import { authApi } from '@/api/authApi'
import { exitDemo } from '@/demo'
// 세션 토큰은 Capacitor Preferences(네이티브 영속 저장 / 웹은 localStorage)로 보관한다. // 세션 토큰은 Capacitor Preferences(네이티브 영속 저장 / 웹은 localStorage)로 보관한다.
// http.js 요청 인터셉터가 동기로 읽을 수 있도록 localStorage 에도 미러링한다. // http.js 요청 인터셉터가 동기로 읽을 수 있도록 localStorage 에도 미러링한다.
@@ -59,6 +60,7 @@ export const useAuthStore = defineStore('auth', () => {
const res = await authApi.login({ loginId, password, rememberMe }) const res = await authApi.login({ loginId, password, rememberMe })
token.value = res.token token.value = res.token
user.value = res.member user.value = res.member
exitDemo()
await persist() await persist()
return res return res
} }
@@ -68,6 +70,7 @@ export const useAuthStore = defineStore('auth', () => {
const res = await authApi.googleLogin({ idToken, rememberMe }) const res = await authApi.googleLogin({ idToken, rememberMe })
token.value = res.token token.value = res.token
user.value = res.member user.value = res.member
exitDemo()
await persist() await persist()
return res return res
} }
@@ -100,6 +103,7 @@ export const useAuthStore = defineStore('auth', () => {
} }
async function clear() { async function clear() {
exitDemo()
token.value = '' token.value = ''
user.value = null user.value = null
await persist() await persist()
+84
View File
@@ -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())
}
+66
View File
@@ -0,0 +1,66 @@
<script setup>
// 둘러보기(데모)에서 미지원(로그인 필요) 화면에 들어왔을 때 안내.
import { useRoute } from 'vue-router'
import { useUiStore } from '@/stores/ui'
const route = useRoute()
const ui = useUiStore()
const LABELS = {
'account-stats': '통계',
'account-budget': '예산 설정',
'account-tags': '태그 관리',
board: '게시판',
'board-detail': '게시판',
settings: '설정',
users: '회원 관리',
}
const label = LABELS[route.query.from] || '이 기능'
function goLogin() {
ui.openLogin('/account')
}
</script>
<template>
<div class="locked">
<div class="lock-big">🔒</div>
<h2>{{ label }} 로그인 이용할 있어요</h2>
<p class="desc">둘러보기에서는 가계부 내역 · 고정 지출 · 계좌 관리 · 분류 관리만 미리 있습니다.<br />로그인하면 통계 · 예산 · 태그 · 게시판까지 모두 사용할 있어요.</p>
<button type="button" class="cta-btn" @click="goLogin">로그인 / 시작하기</button>
</div>
</template>
<style scoped>
.locked {
text-align: center;
padding: 3rem 1rem;
border: 1px dashed var(--color-border);
border-radius: 12px;
}
.lock-big {
font-size: 2.4rem;
}
h2 {
margin-top: 0.6rem;
font-size: 1.15rem;
}
.desc {
margin: 0.6rem 0 1.3rem;
font-size: 0.9rem;
opacity: 0.7;
line-height: 1.6;
}
.cta-btn {
width: 100%;
max-width: 320px;
padding: 0.8rem 1rem;
border: 1px solid hsla(160, 100%, 37%, 1);
border-radius: 8px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
</style>
-470
View File
@@ -1,470 +0,0 @@
<script setup>
// 로그인 없이 둘러보기 — 자체 메뉴로 가계부/고정지출/계좌/분류를 샘플(더미) 데이터로 보여준다.
// 나머지 메뉴는 로그인 유도 안내. API 호출/실데이터 없음.
import { ref, computed } from 'vue'
import { useUiStore } from '@/stores/ui'
const ui = useUiStore()
const MENU = [
{ key: 'account', label: '가계부 내역' },
{ key: 'stats', label: '통계', locked: true },
{ key: 'recurrings', label: '고정 지출' },
{ key: 'wallets', label: '계좌 관리' },
{ key: 'categories', label: '분류 관리' },
{ key: 'budget', label: '예산 설정', locked: true },
{ key: 'tags', label: '태그 관리', locked: true },
{ key: 'board', label: '커뮤니티', locked: true },
]
const active = ref('account')
const activeItem = computed(() => MENU.find((m) => m.key === active.value) || MENU[0])
// ===== 더미 데이터 =====
const summary = { income: 4600000, expense: 1827600, balance: 2772400 }
const accountGroups = [
{ date: '6월 27일 (금)', total: -42300, items: [
{ cat: '식비', memo: '점심 · 김밥천국', type: 'EXPENSE', amount: 9000 },
{ cat: '카페/간식', memo: '스타벅스', type: 'EXPENSE', amount: 5300 },
{ cat: '교통', memo: '지하철', type: 'EXPENSE', amount: 2800 },
{ cat: '쇼핑', memo: '다이소', type: 'EXPENSE', amount: 25200 },
] },
{ date: '6월 25일 (수)', total: 4554700, items: [
{ cat: '급여', memo: '6월 급여', type: 'INCOME', amount: 4600000 },
{ cat: '통신', memo: '휴대폰 요금', type: 'EXPENSE', amount: 45300 },
] },
{ date: '6월 23일 (월)', total: -68000, items: [
{ cat: 'OTT/구독', memo: '넷플릭스', type: 'EXPENSE', amount: 17000 },
{ cat: '식비', memo: '저녁 · 배달', type: 'EXPENSE', amount: 23000 },
{ cat: '생활', memo: '마트 장보기', type: 'EXPENSE', amount: 28000 },
] },
]
const recurrings = [
{ title: '월세', freq: '매월 1일', cat: '주거', type: 'EXPENSE', amount: 600000 },
{ title: '통신비', freq: '매월 15일', cat: '통신', type: 'EXPENSE', amount: 45300 },
{ title: '넷플릭스', freq: '매월 23일', cat: 'OTT/구독', type: 'EXPENSE', amount: 17000 },
{ title: '정기적금', freq: '매월 25일', cat: '저축', type: 'TRANSFER', amount: 300000 },
{ title: '급여', freq: '매월 25일', cat: '급여', type: 'INCOME', amount: 4600000 },
]
const wallets = {
total: 15520000,
items: [
{ name: '신한 주거래', kind: '은행', amount: 2540000 },
{ name: '주택청약', kind: '은행', amount: 5000000 },
{ name: '현금', kind: '현금', amount: 120000 },
{ name: '삼성카드', kind: '카드', amount: -340000 },
{ name: '연금저축(투자)', kind: '투자', amount: 8200000 },
],
}
const categories = [
{ major: '식비', subs: ['점심', '저녁', '카페/간식', '배달'] },
{ major: '교통', subs: ['대중교통', '택시', '주유'] },
{ major: '주거/통신', subs: ['월세', '관리비', '통신비'] },
{ major: '문화/여가', subs: ['OTT/구독', '영화', '여행'] },
{ major: '쇼핑', subs: ['생활', '의류', '온라인'] },
]
function won(n) {
return (n < 0 ? '-' : '') + Math.abs(n).toLocaleString('ko-KR')
}
function typeLabel(t) {
return t === 'INCOME' ? '수입' : t === 'TRANSFER' ? '이체' : '지출'
}
function goLogin() {
ui.openLogin('/account')
}
</script>
<template>
<section class="demo">
<div class="banner">
<span>👀 <b>둘러보기 모드</b> · 아래는 샘플 데이터입니다.</span>
<button type="button" class="login-btn" @click="goLogin">로그인하고 시작</button>
</div>
<div class="demo-shell">
<!-- 데모 메뉴 -->
<nav class="demo-menu">
<button
v-for="m in MENU" :key="m.key" type="button"
class="dm-item" :class="{ active: active === m.key }"
@click="active = m.key"
>
<span>{{ m.label }}</span>
<span v-if="m.locked" class="lock">🔒</span>
</button>
</nav>
<!-- 콘텐츠 -->
<div class="demo-content">
<!-- 가계부 내역 -->
<template v-if="active === 'account'">
<div class="month-nav"><span class="chev"></span><span class="period">2026 6</span><span class="chev"></span></div>
<div class="summary">
<div class="s-card"><span class="s-label">수입</span><span class="s-val income">{{ won(summary.income) }}</span></div>
<div class="s-card"><span class="s-label">지출</span><span class="s-val expense">{{ won(summary.expense) }}</span></div>
<div class="s-card"><span class="s-label">잔액</span><span class="s-val">{{ won(summary.balance) }}</span></div>
</div>
<div v-for="g in accountGroups" :key="g.date" class="card-group">
<div class="cg-head">
<span class="cg-title">{{ g.date }}</span>
<span class="cg-total" :class="g.total >= 0 ? 'income' : 'expense'">{{ won(g.total) }}</span>
</div>
<ul class="rows">
<li v-for="(e, i) in g.items" :key="i" class="row3">
<span class="tag">{{ e.cat }}</span>
<span class="ellip">{{ e.memo }}</span>
<span class="amt" :class="e.type === 'INCOME' ? 'income' : 'expense'">{{ e.type === 'INCOME' ? '+' : '-' }}{{ won(e.amount) }}</span>
</li>
</ul>
</div>
</template>
<!-- 고정 지출 -->
<template v-else-if="active === 'recurrings'">
<p class="hint">등록한 주기에 맞춰 가계부에 자동으로 '확인 필요' 내역이 생성됩니다.</p>
<div class="card-group">
<ul class="rows">
<li v-for="(r, i) in recurrings" :key="i" class="row-rec">
<div class="rec-main">
<span class="rec-title">{{ r.title }}</span>
<span class="rec-sub"><span class="tag">{{ r.cat }}</span> · {{ r.freq }} · {{ typeLabel(r.type) }}</span>
</div>
<span class="amt" :class="r.type === 'INCOME' ? 'income' : r.type === 'TRANSFER' ? '' : 'expense'">{{ won(r.amount) }}</span>
</li>
</ul>
</div>
</template>
<!-- 계좌 관리 -->
<template v-else-if="active === 'wallets'">
<div class="networth">
<span class="nw-label">총자산</span>
<span class="nw-val income">{{ won(wallets.total) }}</span>
</div>
<div class="card-group">
<ul class="rows">
<li v-for="(w, i) in wallets.items" :key="i" class="row-rec">
<div class="rec-main">
<span class="rec-title">{{ w.name }}</span>
<span class="rec-sub"><span class="tag">{{ w.kind }}</span></span>
</div>
<span class="amt" :class="w.amount < 0 ? 'expense' : ''">{{ won(w.amount) }}</span>
</li>
</ul>
</div>
</template>
<!-- 분류 관리 -->
<template v-else-if="active === 'categories'">
<p class="hint">대분류 아래 소분류로 묶어 관리합니다. (예산·통계는 소분류 기준)</p>
<div v-for="(c, i) in categories" :key="i" class="card-group">
<div class="cg-head"><span class="major-badge"></span><span class="cg-title">{{ c.major }}</span></div>
<ul class="rows">
<li v-for="(s, j) in c.subs" :key="j" class="row-sub2"><span class="sub-mark"></span>{{ s }}</li>
</ul>
</div>
</template>
<!-- 잠금(로그인 유도) -->
<div v-else class="locked-box">
<div class="lock-big">🔒</div>
<p class="lock-title">{{ activeItem.label }} 로그인 이용할 있어요</p>
<p class="lock-desc">로그인하면 통계·예산·태그·게시판까지 모두 사용할 있습니다.</p>
<button type="button" class="cta-btn" @click="goLogin">로그인 / 시작하기</button>
</div>
</div>
</div>
<div class="cta">
<p>마음에 드시나요? 로그인하면 <b> 가계부</b> 바로 시작할 있어요.</p>
<button type="button" class="cta-btn" @click="goLogin">로그인 / 시작하기</button>
</div>
</section>
</template>
<style scoped>
.demo {
padding-bottom: 1rem;
}
.banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
padding: 0.7rem 0.9rem;
margin-bottom: 1rem;
border: 1px solid hsla(160, 100%, 37%, 0.4);
border-radius: 8px;
background: hsla(160, 100%, 37%, 0.08);
font-size: 0.9rem;
}
.login-btn,
.cta-btn {
padding: 0.45rem 0.9rem;
border: 1px solid hsla(160, 100%, 37%, 1);
border-radius: 6px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.demo-shell {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.demo-menu {
flex: none;
width: 150px;
display: flex;
flex-direction: column;
gap: 0.15rem;
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 0.4rem;
background: var(--color-background-soft);
}
.dm-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.3rem;
padding: 0.55rem 0.6rem;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--color-text);
font-size: 0.9rem;
text-align: left;
cursor: pointer;
}
.dm-item:hover {
background: var(--color-background-mute);
}
.dm-item.active {
background: hsla(160, 100%, 37%, 0.12);
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
}
.lock {
font-size: 0.75rem;
opacity: 0.6;
}
.demo-content {
flex: 1;
min-width: 0;
}
.month-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-bottom: 1rem;
}
.chev {
font-size: 1.4rem;
opacity: 0.5;
}
.period {
font-size: 1.1rem;
font-weight: 600;
}
.summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.6rem;
margin-bottom: 1.25rem;
}
.s-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.7rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-background-soft);
}
.s-label {
font-size: 0.78rem;
opacity: 0.6;
}
.s-val {
font-size: 1rem;
font-weight: 700;
}
.income {
color: hsla(160, 100%, 37%, 1);
}
.expense {
color: #c0392b;
}
.hint {
font-size: 0.85rem;
opacity: 0.7;
margin-bottom: 0.8rem;
}
.networth {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
margin-bottom: 1rem;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-background-soft);
}
.nw-label {
font-size: 0.85rem;
opacity: 0.65;
}
.nw-val {
font-size: 1.3rem;
font-weight: 800;
}
.card-group {
margin-bottom: 1rem;
border: 1px solid var(--color-border);
border-radius: 10px;
overflow: hidden;
}
.cg-head {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: space-between;
padding: 0.6rem 0.9rem;
background: var(--color-background-mute);
font-size: 0.88rem;
}
.cg-title {
font-weight: 600;
margin-right: auto;
}
.cg-total {
font-weight: 700;
}
.rows {
list-style: none;
margin: 0;
padding: 0;
}
.row3 {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 0.9rem;
border-top: 1px solid var(--color-border);
}
.row-rec {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
padding: 0.65rem 0.9rem;
border-top: 1px solid var(--color-border);
}
.row-rec:first-child {
border-top: 0;
}
.rec-main {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.rec-title {
font-weight: 600;
font-size: 0.92rem;
}
.rec-sub {
font-size: 0.78rem;
opacity: 0.7;
}
.row-sub2 {
padding: 0.55rem 0.9rem;
border-top: 1px solid var(--color-border);
font-size: 0.9rem;
}
.sub-mark {
opacity: 0.4;
margin-right: 0.4rem;
}
.major-badge {
font-size: 0.7rem;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: hsla(160, 100%, 37%, 0.15);
color: hsla(160, 100%, 37%, 1);
font-weight: 700;
}
.tag {
font-size: 0.75rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: var(--color-background-mute);
white-space: nowrap;
}
.ellip {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.92rem;
}
.amt {
font-weight: 700;
white-space: nowrap;
}
.locked-box {
text-align: center;
padding: 2.5rem 1rem;
border: 1px dashed var(--color-border);
border-radius: 12px;
}
.lock-big {
font-size: 2.2rem;
}
.lock-title {
margin-top: 0.5rem;
font-size: 1.05rem;
font-weight: 700;
}
.lock-desc {
margin: 0.4rem 0 1.1rem;
font-size: 0.88rem;
opacity: 0.7;
}
.cta {
margin-top: 1.5rem;
text-align: center;
}
.cta p {
font-size: 0.92rem;
opacity: 0.8;
margin-bottom: 0.75rem;
}
.cta .cta-btn {
width: 100%;
max-width: 320px;
padding: 0.8rem 1rem;
font-size: 1rem;
}
@media (max-width: 768px) {
.demo-shell {
flex-direction: column;
}
.demo-menu {
width: 100%;
flex-direction: row;
overflow-x: auto;
gap: 0.3rem;
}
.dm-item {
flex: none;
white-space: nowrap;
}
}
</style>
+13 -2
View File
@@ -5,6 +5,12 @@ import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui' import { useUiStore } from '@/stores/ui'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
import { ID_LOGIN_ENABLED } from '@/config/features' import { ID_LOGIN_ENABLED } from '@/config/features'
import { enterDemo } from '@/demo'
function startDemo() {
enterDemo()
router.push('/account/entries')
}
const auth = useAuthStore() const auth = useAuthStore()
const ui = useUiStore() const ui = useUiStore()
@@ -299,7 +305,7 @@ onMounted(load)
<div class="cta"> <div class="cta">
<button type="button" class="btn primary" @click="ui.openLogin('/account')">로그인</button> <button type="button" class="btn primary" @click="ui.openLogin('/account')">로그인</button>
<button v-if="ID_LOGIN_ENABLED && ui.signupEnabled" type="button" class="btn" @click="ui.openSignup()">회원가입</button> <button v-if="ID_LOGIN_ENABLED && ui.signupEnabled" type="button" class="btn" @click="ui.openSignup()">회원가입</button>
<button type="button" class="btn" @click="router.push('/demo')">로그인 없이 둘러보기</button> <button type="button" class="btn" @click="startDemo">로그인 없이 둘러보기</button>
</div> </div>
<p class="cta-note">{{ !ID_LOGIN_ENABLED || ui.signupEnabled ? '로그인하면 나의 가계부 요약을 볼 수 있어요.' : '현재 회원가입이 제한되어 있습니다.' }}</p> <p class="cta-note">{{ !ID_LOGIN_ENABLED || ui.signupEnabled ? '로그인하면 나의 가계부 요약을 볼 수 있어요.' : '현재 회원가입이 제한되어 있습니다.' }}</p>
</div> </div>
@@ -694,10 +700,15 @@ onMounted(load)
} }
.cta { .cta {
display: flex; display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem; gap: 0.6rem;
justify-content: center;
margin-top: 1.4rem; margin-top: 1.4rem;
} }
.cta .btn {
width: 100%;
max-width: 260px;
}
.btn { .btn {
padding: 0.6rem 1.4rem; padding: 0.6rem 1.4rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
+35
View File
@@ -4,9 +4,26 @@ import { RouterLink } from 'vue-router'
import { Preferences } from '@capacitor/preferences' import { Preferences } from '@capacitor/preferences'
import { useDialog } from '@/composables/dialog' import { useDialog } from '@/composables/dialog'
import { useUiStore } from '@/stores/ui' import { useUiStore } from '@/stores/ui'
import { exportBackup } from '@/utils/backup'
const dialog = useDialog() const dialog = useDialog()
const ui = useUiStore() 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 = [ const THEMES = [
{ value: 'system', label: '시스템' }, { value: 'system', label: '시스템' },
{ value: 'light', label: '라이트' }, { value: 'light', label: '라이트' },
@@ -92,6 +109,24 @@ async function clearAppData() {
</div> </div>
</section> </section>
<!-- 데이터 백업 -->
<section class="card">
<button type="button" class="row row-btn" :disabled="exporting" @click="doExport">
<div class="row-main">
<span class="row-label">엑셀로 내보내기</span>
<span class="row-sub">가계부 전체 데이터를 .xlsx 파일로 저장</span>
</div>
<span class="row-value">{{ exporting ? '내보내는 중…' : '내보내기' }}</span>
</button>
<button type="button" class="row row-btn" @click="importBackup">
<div class="row-main">
<span class="row-label">엑셀에서 가져오기</span>
<span class="row-sub">백업 파일로 데이터 복구</span>
</div>
<span class="row-value">가져오기</span>
</button>
</section>
<section class="card"> <section class="card">
<RouterLink to="/settings/account" class="row row-link"> <RouterLink to="/settings/account" class="row row-link">
<div class="row-main"> <div class="row-main">