- auth 스토어 isPremium/isAdmin 추가 (plan===PREMIUM 또는 관리자) - 사이드바: 통계·고정지출·예산·태그 메뉴에 무료 회원 잠금 배지(🔒) - 라우트 가드: 유료 전용 화면(requiresPremium) → 무료 회원은 /upgrade - UpgradeView: 무료/유료 기능 비교 + 업그레이드 안내 - http 인터셉터: 403 PREMIUM_REQUIRED → premium:required 이벤트 → 업그레이드 화면 - 무료 화면의 유료 API 호출 차단(홈 예산·내역 태그·OCR·카드알림 게이팅) - 회원관리: 멤버십 컬럼 + 무료/유료 토글(관리자) - 설정: 데이터 백업/복구 유료 게이팅, 계정정보에 멤버십 표시 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -113,10 +113,11 @@ async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
// 예산은 유료 전용 — 무료 회원은 호출하지 않음(403/업그레이드 리다이렉트 방지)
|
||||
const [sum, nw, status, list] = await Promise.all([
|
||||
accountApi.summary({ year, month }),
|
||||
accountApi.netWorth(),
|
||||
accountApi.budgetStatus({ year, month }),
|
||||
auth.isPremium ? accountApi.budgetStatus({ year, month }) : Promise.resolve([]),
|
||||
accountApi.list({ year, month }),
|
||||
])
|
||||
summary.value = sum
|
||||
@@ -204,12 +205,14 @@ onMounted(load)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 예산 대비 지출 -->
|
||||
<!-- 예산 대비 지출 (유료 전용 — 무료 회원은 업그레이드 안내) -->
|
||||
<div class="card budget-card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">{{ month }}월 예산 대비 지출</span>
|
||||
<RouterLink to="/account/budget" class="more">예산 →</RouterLink>
|
||||
<RouterLink :to="auth.isPremium ? '/account/budget' : '/upgrade'" class="more">예산 →</RouterLink>
|
||||
</div>
|
||||
<RouterLink v-if="!auth.isPremium" to="/upgrade" class="budget-empty">👑 예산 관리는 유료 멤버십 기능이에요 →</RouterLink>
|
||||
<template v-else>
|
||||
<template v-if="budgetTotal > 0">
|
||||
<div class="budget-bar">
|
||||
<div
|
||||
@@ -225,7 +228,8 @@ onMounted(load)
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<RouterLink v-else to="/account/budget" class="budget-empty">예산을 설정하면 지출 진행률을 볼 수 있어요 →</RouterLink>
|
||||
<RouterLink v-else to="/account/budget" class="budget-empty">예산을 설정하면 지출 진행률을 볼 수 있어요 →</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 일별 수입/지출 캘린더 -->
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const isPremium = computed(() => auth.isPremium)
|
||||
|
||||
// 무료 / 유료 기능 비교 (docs/plan-membership-tiers.md 기준)
|
||||
const freeFeatures = [
|
||||
'가계부 내역 기록 · 조회',
|
||||
'계좌 관리 (개수 제한 없음)',
|
||||
'자주 쓰는 내역',
|
||||
'월별 수입/지출 요약',
|
||||
'기본 분류',
|
||||
'게시판 · 다크 모드',
|
||||
]
|
||||
const premiumFeatures = [
|
||||
'전체 통계 (분류별·추이·자산)',
|
||||
'예산 설정',
|
||||
'고정 지출 자동 반영',
|
||||
'영수증 OCR 인식',
|
||||
'카드 알림 자동 인식',
|
||||
'투자 · 자산 관리',
|
||||
'태그 · 소분류 무제한',
|
||||
'데이터 백업 / 복구 (엑셀)',
|
||||
'광고 제거',
|
||||
]
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) router.back()
|
||||
else router.push('/account/entries')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="upgrade">
|
||||
<header class="up-head">
|
||||
<div class="crown">👑</div>
|
||||
<h1 v-if="isPremium">유료 멤버십 이용 중</h1>
|
||||
<h1 v-else>유료 멤버십으로 더 많은 기능을</h1>
|
||||
<p v-if="isPremium" class="sub">모든 프리미엄 기능을 사용할 수 있습니다. 감사합니다!</p>
|
||||
<p v-else class="sub">통계·예산·자동화·백업까지, 가계부를 200% 활용하세요.</p>
|
||||
</header>
|
||||
|
||||
<div class="plans">
|
||||
<section class="plan">
|
||||
<h2>무료</h2>
|
||||
<ul>
|
||||
<li v-for="f in freeFeatures" :key="f"><span class="dot ok">✓</span>{{ f }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="plan premium">
|
||||
<h2>유료 <span class="badge">PREMIUM</span></h2>
|
||||
<ul>
|
||||
<li v-for="f in premiumFeatures" :key="f"><span class="dot pro">★</span>{{ f }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="cta">
|
||||
<p v-if="!isPremium" class="notice">
|
||||
결제 기능은 준비 중입니다. 먼저 사용해 보고 싶다면 관리자에게 문의해 주세요.
|
||||
</p>
|
||||
<button type="button" class="ghost" @click="goBack">돌아가기</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.upgrade {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.up-head {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.crown {
|
||||
font-size: 2.4rem;
|
||||
}
|
||||
.up-head h1 {
|
||||
font-size: 1.4rem;
|
||||
margin: 0.4rem 0 0.3rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
.sub {
|
||||
color: var(--color-text);
|
||||
opacity: 0.8;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.plans {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
.plan {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 1.1rem 1.2rem;
|
||||
background: var(--color-background-soft);
|
||||
}
|
||||
.plan.premium {
|
||||
border-color: hsla(40, 90%, 55%, 0.7);
|
||||
background: hsla(40, 90%, 55%, 0.06);
|
||||
}
|
||||
.plan h2 {
|
||||
font-size: 1.05rem;
|
||||
margin: 0 0 0.8rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
.badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: hsla(40, 90%, 50%, 0.9);
|
||||
color: #3a2a00;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.plan ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.plan li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.dot {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.dot.ok {
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.dot.pro {
|
||||
color: hsla(40, 90%, 50%, 1);
|
||||
}
|
||||
.cta {
|
||||
margin-top: 1.6rem;
|
||||
text-align: center;
|
||||
}
|
||||
.notice {
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text);
|
||||
opacity: 0.85;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
.ghost {
|
||||
padding: 0.55rem 1.4rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.plans {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+31
-1
@@ -79,6 +79,17 @@ async function changeRole(m, role) {
|
||||
}
|
||||
}
|
||||
|
||||
async function changePlan(m, plan) {
|
||||
if (plan === m.plan) return
|
||||
try {
|
||||
const updated = await adminApi.updateMemberPlan(m.id, plan)
|
||||
Object.assign(m, updated)
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '멤버십 변경 실패')
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
async function changeStatus(m, status) {
|
||||
if (status === m.status) return
|
||||
try {
|
||||
@@ -114,7 +125,7 @@ onMounted(() => {
|
||||
<IconBtn icon="refresh" title="새로고침" size="sm" :disabled="loading" @click="load" />
|
||||
</div>
|
||||
</header>
|
||||
<p class="hint">가입한 회원의 역할·상태를 관리합니다. (관리자 전용)</p>
|
||||
<p class="hint">가입한 회원의 역할·멤버십·상태를 관리합니다. (관리자 전용)</p>
|
||||
|
||||
<!-- 회원가입 허용 토글 -->
|
||||
<div class="signup-toggle" :class="{ off: !signupEnabled }">
|
||||
@@ -139,6 +150,7 @@ onMounted(() => {
|
||||
<th>아이디 / 이름</th>
|
||||
<th class="c-email">이메일</th>
|
||||
<th class="c-role">역할</th>
|
||||
<th class="c-plan">멤버십</th>
|
||||
<th class="c-status">상태</th>
|
||||
<th class="c-date">가입일</th>
|
||||
<th class="c-act"></th>
|
||||
@@ -158,6 +170,16 @@ onMounted(() => {
|
||||
<option value="ADMIN">ADMIN</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="c-plan">
|
||||
<select
|
||||
:value="m.plan || 'FREE'"
|
||||
class="plan-sel" :class="(m.plan || 'FREE').toLowerCase()"
|
||||
@change="changePlan(m, $event.target.value)"
|
||||
>
|
||||
<option value="FREE">무료</option>
|
||||
<option value="PREMIUM">유료</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="c-status">
|
||||
<select
|
||||
:value="m.status" :disabled="m.id === myId"
|
||||
@@ -289,6 +311,7 @@ h1 {
|
||||
width: 48px;
|
||||
}
|
||||
.c-role,
|
||||
.c-plan,
|
||||
.c-status {
|
||||
width: 90px;
|
||||
}
|
||||
@@ -347,6 +370,13 @@ h1 {
|
||||
.status-sel.withdrawn {
|
||||
color: #c0392b;
|
||||
}
|
||||
.plan-sel.premium {
|
||||
color: #b8860b;
|
||||
font-weight: 600;
|
||||
}
|
||||
.plan-sel.free {
|
||||
color: var(--color-text);
|
||||
}
|
||||
.msg {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useDialog } from '@/composables/dialog'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr'
|
||||
@@ -9,6 +10,8 @@ import { Capacitor } from '@capacitor/core'
|
||||
// @capacitor/camera 는 네이티브에서만 동적 로드 (웹은 file input 사용 — 정적 import 시 모바일 웹 청크 로드 실패 유발)
|
||||
|
||||
const dialog = useDialog()
|
||||
const auth = useAuthStore()
|
||||
const isPremium = computed(() => auth.isPremium) // 태그·OCR 등 유료 기능 노출 제어
|
||||
const now = new Date()
|
||||
const year = ref(now.getFullYear())
|
||||
const month = ref(now.getMonth() + 1)
|
||||
@@ -329,6 +332,11 @@ function toggleTag(id) {
|
||||
}
|
||||
|
||||
async function loadTagOptions() {
|
||||
// 태그는 유료 전용 — 무료 회원은 호출하지 않음(403/업그레이드 리다이렉트 방지)
|
||||
if (!isPremium.value) {
|
||||
tagOptions.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
tagOptions.value = await accountApi.tags()
|
||||
} catch {
|
||||
@@ -832,7 +840,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<!-- 카드 결제 알림 자동인식 권한 안내 (네이티브, 미허용 시) -->
|
||||
<div v-if="notifNative && !notifEnabled" class="notif-banner">
|
||||
<div v-if="isPremium && notifNative && !notifEnabled" class="notif-banner">
|
||||
<span>💳 카드 결제 알림을 자동으로 가계부에 등록하려면 <b>알림 접근 권한</b>이 필요합니다.</span>
|
||||
<button type="button" class="notif-btn" @click="openNotifSettings">설정 열기</button>
|
||||
</div>
|
||||
@@ -959,6 +967,7 @@ onMounted(async () => {
|
||||
/>
|
||||
<div class="rb-actions">
|
||||
<button
|
||||
v-if="isPremium"
|
||||
type="button" class="receipt-btn"
|
||||
:disabled="submitting || ocrRunning" @click="pickReceipt"
|
||||
>📷 영수증 스캔</button>
|
||||
@@ -1074,7 +1083,7 @@ onMounted(async () => {
|
||||
<label v-if="!isRepayment">금액<input v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
|
||||
|
||||
<div v-if="!isRepayment" class="tag-field">
|
||||
<div v-if="!isRepayment && isPremium" class="tag-field">
|
||||
<span class="tag-label">태그</span>
|
||||
<div class="tag-badges">
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
@@ -8,6 +8,8 @@ const router = useRouter()
|
||||
|
||||
const u = computed(() => auth.user || {})
|
||||
const roleLabel = computed(() => (u.value.role === 'ADMIN' ? '관리자' : '일반회원'))
|
||||
const isPremium = computed(() => auth.isPremium)
|
||||
const planLabel = computed(() => (isPremium.value ? '유료 멤버십' : '무료'))
|
||||
|
||||
function goEdit() {
|
||||
router.push('/settings/account/edit')
|
||||
@@ -30,6 +32,13 @@ function goEdit() {
|
||||
<span class="k">권한</span>
|
||||
<span class="v">{{ roleLabel }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="k">멤버십</span>
|
||||
<span class="v">
|
||||
<span :class="{ premium: isPremium }">{{ planLabel }}</span>
|
||||
<RouterLink v-if="!isPremium" to="/upgrade" class="up-link">업그레이드 →</RouterLink>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button type="button" class="primary-btn" @click="goEdit">이름 변경</button>
|
||||
@@ -92,6 +101,15 @@ function goEdit() {
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
.v .premium {
|
||||
color: #b8860b;
|
||||
}
|
||||
.up-link {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
}
|
||||
.primary-btn {
|
||||
width: 100%;
|
||||
margin-top: 1.25rem;
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { useDialog } from '@/composables/dialog'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { exportBackup, importBackup } from '@/utils/backup'
|
||||
|
||||
const dialog = useDialog()
|
||||
const ui = useUiStore()
|
||||
const auth = useAuthStore()
|
||||
const isPremium = computed(() => auth.isPremium) // 데이터 백업/복구는 유료 전용
|
||||
|
||||
const exporting = ref(false)
|
||||
async function doExport() {
|
||||
@@ -143,23 +146,34 @@ async function clearAppData() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 데이터 백업 -->
|
||||
<!-- 데이터 백업 (유료 전용) -->
|
||||
<section class="card">
|
||||
<button type="button" class="row row-btn" :disabled="exporting" @click="doExport">
|
||||
<template v-if="isPremium">
|
||||
<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" :disabled="importing" @click="pickImportFile">
|
||||
<div class="row-main">
|
||||
<span class="row-label">엑셀에서 가져오기</span>
|
||||
<span class="row-sub">백업 파일로 데이터 복구 (기존 데이터 덮어씀)</span>
|
||||
</div>
|
||||
<span class="row-value">{{ importing ? '복구 중…' : '가져오기' }}</span>
|
||||
</button>
|
||||
<input ref="fileInput" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" class="hidden-file" @change="onImportFile" />
|
||||
</template>
|
||||
<RouterLink v-else to="/upgrade" class="row row-link">
|
||||
<div class="row-main">
|
||||
<span class="row-label">엑셀로 내보내기</span>
|
||||
<span class="row-sub">가계부 전체 데이터를 .xlsx 파일로 저장</span>
|
||||
<span class="row-label">데이터 백업 / 복구 👑</span>
|
||||
<span class="row-sub">엑셀 내보내기·가져오기는 유료 멤버십 기능입니다</span>
|
||||
</div>
|
||||
<span class="row-value">{{ exporting ? '내보내는 중…' : '내보내기' }}</span>
|
||||
</button>
|
||||
<button type="button" class="row row-btn" :disabled="importing" @click="pickImportFile">
|
||||
<div class="row-main">
|
||||
<span class="row-label">엑셀에서 가져오기</span>
|
||||
<span class="row-sub">백업 파일로 데이터 복구 (기존 데이터 덮어씀)</span>
|
||||
</div>
|
||||
<span class="row-value">{{ importing ? '복구 중…' : '가져오기' }}</span>
|
||||
</button>
|
||||
<input ref="fileInput" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" class="hidden-file" @change="onImportFile" />
|
||||
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
|
||||
Reference in New Issue
Block a user