- 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:
+9
-1
@@ -36,12 +36,20 @@ function onUnauthorized() {
|
|||||||
auth.clear()
|
auth.clear()
|
||||||
ui.openLogin()
|
ui.openLogin()
|
||||||
}
|
}
|
||||||
|
// http.js 의 403 PREMIUM_REQUIRED 처리 → 업그레이드 안내 화면으로
|
||||||
|
function onPremiumRequired() {
|
||||||
|
if (route.name !== 'upgrade') router.push({ name: 'upgrade' })
|
||||||
|
}
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!isApp) return // 웹은 안내만 — 앱 전용 초기화 생략
|
if (!isApp) return // 웹은 안내만 — 앱 전용 초기화 생략
|
||||||
window.addEventListener('auth:unauthorized', onUnauthorized)
|
window.addEventListener('auth:unauthorized', onUnauthorized)
|
||||||
|
window.addEventListener('premium:required', onPremiumRequired)
|
||||||
ui.loadSignupEnabled() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영)
|
ui.loadSignupEnabled() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영)
|
||||||
})
|
})
|
||||||
onUnmounted(() => window.removeEventListener('auth:unauthorized', onUnauthorized))
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('auth:unauthorized', onUnauthorized)
|
||||||
|
window.removeEventListener('premium:required', onPremiumRequired)
|
||||||
|
})
|
||||||
|
|
||||||
// 페이지 이동 시 모바일 사이드바 자동 닫힘
|
// 페이지 이동 시 모바일 사이드바 자동 닫힘
|
||||||
watch(() => route.fullPath, () => ui.closeSidebar())
|
watch(() => route.fullPath, () => ui.closeSidebar())
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export const adminApi = {
|
|||||||
updateMemberRole(id, role) {
|
updateMemberRole(id, role) {
|
||||||
return http.put(`/admin/members/${id}/role`, { role })
|
return http.put(`/admin/members/${id}/role`, { role })
|
||||||
},
|
},
|
||||||
|
updateMemberPlan(id, plan) {
|
||||||
|
return http.put(`/admin/members/${id}/plan`, { plan })
|
||||||
|
},
|
||||||
updateMemberStatus(id, status) {
|
updateMemberStatus(id, status) {
|
||||||
return http.put(`/admin/members/${id}/status`, { status })
|
return http.put(`/admin/members/${id}/status`, { status })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ http.interceptors.response.use(
|
|||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
localStorage.removeItem('auth_user')
|
localStorage.removeItem('auth_user')
|
||||||
window.dispatchEvent(new CustomEvent('auth:unauthorized'))
|
window.dispatchEvent(new CustomEvent('auth:unauthorized'))
|
||||||
|
} else if (status === 403 && error.response?.data?.code === 'PREMIUM_REQUIRED') {
|
||||||
|
// 유료 전용 API 를 무료 회원이 호출(우회/직접 접근) — 업그레이드 안내
|
||||||
|
window.dispatchEvent(new CustomEvent('premium:required'))
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const auth = useAuthStore()
|
|||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
// 로그인 또는 둘러보기(데모)면 메뉴 노출. 데모에선 잠금 배지를 함께 표시.
|
// 로그인 또는 둘러보기(데모)면 메뉴 노출. 데모에선 잠금 배지를 함께 표시.
|
||||||
const showMenu = computed(() => auth.isAuthenticated || demo.on)
|
const showMenu = computed(() => auth.isAuthenticated || demo.on)
|
||||||
|
// 유료 전용 메뉴 잠금 배지: 데모이거나, 로그인했지만 무료 회원일 때.
|
||||||
|
const lockPremium = computed(() => demo.on || (auth.isAuthenticated && !auth.isPremium))
|
||||||
|
|
||||||
// 메뉴 아이콘 (lucide 스타일 인라인 SVG path — 하단 내비와 동일 톤). 값은 정적/신뢰 마크업.
|
// 메뉴 아이콘 (lucide 스타일 인라인 SVG path — 하단 내비와 동일 톤). 값은 정적/신뢰 마크업.
|
||||||
const icons = {
|
const icons = {
|
||||||
@@ -45,11 +47,12 @@ 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>
|
<span v-if="lockPremium" 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" />
|
||||||
<span>고정 지출</span>
|
<span>고정 지출</span>
|
||||||
|
<span v-if="lockPremium" class="lock-badge">🔒</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink to="/account/wallets" class="menu-item">
|
<RouterLink to="/account/wallets" class="menu-item">
|
||||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.wallets" />
|
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.wallets" />
|
||||||
@@ -62,12 +65,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>
|
<span v-if="lockPremium" 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>
|
<span v-if="lockPremium" class="lock-badge">🔒</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<!-- 게시판 영역 -->
|
<!-- 게시판 영역 -->
|
||||||
|
|||||||
+15
-4
@@ -24,6 +24,13 @@ const router = createRouter({
|
|||||||
name: 'demo-locked',
|
name: 'demo-locked',
|
||||||
component: () => import('../views/DemoLockedView.vue'),
|
component: () => import('../views/DemoLockedView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 유료 멤버십 안내/업그레이드 (로그인 필요, 무료 회원도 접근 가능)
|
||||||
|
path: '/upgrade',
|
||||||
|
name: 'upgrade',
|
||||||
|
component: () => import('../views/UpgradeView.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/users',
|
path: '/users',
|
||||||
name: 'users',
|
name: 'users',
|
||||||
@@ -85,13 +92,13 @@ const router = createRouter({
|
|||||||
path: '/account/stats',
|
path: '/account/stats',
|
||||||
name: 'account-stats',
|
name: 'account-stats',
|
||||||
component: () => import('../views/account/AccountDashboardView.vue'),
|
component: () => import('../views/account/AccountDashboardView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true, requiresPremium: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/account/tags',
|
path: '/account/tags',
|
||||||
name: 'account-tags',
|
name: 'account-tags',
|
||||||
component: () => import('../views/account/AccountTagView.vue'),
|
component: () => import('../views/account/AccountTagView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true, requiresPremium: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/account/wallets',
|
path: '/account/wallets',
|
||||||
@@ -109,13 +116,13 @@ const router = createRouter({
|
|||||||
path: '/account/budget',
|
path: '/account/budget',
|
||||||
name: 'account-budget',
|
name: 'account-budget',
|
||||||
component: () => import('../views/account/BudgetView.vue'),
|
component: () => import('../views/account/BudgetView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true, requiresPremium: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/account/recurrings',
|
path: '/account/recurrings',
|
||||||
name: 'account-recurrings',
|
name: 'account-recurrings',
|
||||||
component: () => import('../views/account/RecurringView.vue'),
|
component: () => import('../views/account/RecurringView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true, requiresPremium: true },
|
||||||
},
|
},
|
||||||
// 설정 (앱 하단 내비게이션 → 설정)
|
// 설정 (앱 하단 내비게이션 → 설정)
|
||||||
{
|
{
|
||||||
@@ -161,6 +168,10 @@ router.beforeEach((to, from) => {
|
|||||||
if (to.meta.requiresAdmin && auth.user?.role !== 'ADMIN') {
|
if (to.meta.requiresAdmin && auth.user?.role !== 'ADMIN') {
|
||||||
return { name: 'home' }
|
return { name: 'home' }
|
||||||
}
|
}
|
||||||
|
// 유료 전용 페이지 — 무료 회원은 업그레이드 안내로
|
||||||
|
if (to.meta.requiresPremium && !auth.isPremium) {
|
||||||
|
return { name: 'upgrade' }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 새 배포로 청크 해시가 바뀐 뒤, 캐시된 옛 index.html 이 삭제된 옛 청크를 부르면
|
// 새 배포로 청크 해시가 바뀐 뒤, 캐시된 옛 index.html 이 삭제된 옛 청크를 부르면
|
||||||
|
|||||||
+4
-1
@@ -15,6 +15,9 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const ready = ref(false)
|
const ready = ref(false)
|
||||||
|
|
||||||
const isAuthenticated = computed(() => !!token.value)
|
const isAuthenticated = computed(() => !!token.value)
|
||||||
|
const isAdmin = computed(() => user.value?.role === 'ADMIN')
|
||||||
|
// 유료(PREMIUM) 멤버십 여부. 관리자는 항상 유료 기능 사용 가능.
|
||||||
|
const isPremium = computed(() => isAdmin.value || user.value?.plan === 'PREMIUM')
|
||||||
|
|
||||||
// localStorage 동기화 (http.js 가 동기로 토큰을 읽음)
|
// localStorage 동기화 (http.js 가 동기로 토큰을 읽음)
|
||||||
function mirrorLocal() {
|
function mirrorLocal() {
|
||||||
@@ -109,7 +112,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
await persist()
|
await persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
return { token, user, ready, isAuthenticated, restore, login, googleLogin, signup, fetchMe, applyUser, logout, clear }
|
return { token, user, ready, isAuthenticated, isAdmin, isPremium, restore, login, googleLogin, signup, fetchMe, applyUser, logout, clear }
|
||||||
})
|
})
|
||||||
|
|
||||||
function safeParse(value) {
|
function safeParse(value) {
|
||||||
|
|||||||
@@ -113,10 +113,11 @@ async function load() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
|
// 예산은 유료 전용 — 무료 회원은 호출하지 않음(403/업그레이드 리다이렉트 방지)
|
||||||
const [sum, nw, status, list] = await Promise.all([
|
const [sum, nw, status, list] = await Promise.all([
|
||||||
accountApi.summary({ year, month }),
|
accountApi.summary({ year, month }),
|
||||||
accountApi.netWorth(),
|
accountApi.netWorth(),
|
||||||
accountApi.budgetStatus({ year, month }),
|
auth.isPremium ? accountApi.budgetStatus({ year, month }) : Promise.resolve([]),
|
||||||
accountApi.list({ year, month }),
|
accountApi.list({ year, month }),
|
||||||
])
|
])
|
||||||
summary.value = sum
|
summary.value = sum
|
||||||
@@ -204,12 +205,14 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 예산 대비 지출 -->
|
<!-- 예산 대비 지출 (유료 전용 — 무료 회원은 업그레이드 안내) -->
|
||||||
<div class="card budget-card">
|
<div class="card budget-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<span class="card-title">{{ month }}월 예산 대비 지출</span>
|
<span class="card-title">{{ month }}월 예산 대비 지출</span>
|
||||||
<RouterLink to="/account/budget" class="more">예산 →</RouterLink>
|
<RouterLink :to="auth.isPremium ? '/account/budget' : '/upgrade'" class="more">예산 →</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
<RouterLink v-if="!auth.isPremium" to="/upgrade" class="budget-empty">👑 예산 관리는 유료 멤버십 기능이에요 →</RouterLink>
|
||||||
|
<template v-else>
|
||||||
<template v-if="budgetTotal > 0">
|
<template v-if="budgetTotal > 0">
|
||||||
<div class="budget-bar">
|
<div class="budget-bar">
|
||||||
<div
|
<div
|
||||||
@@ -226,6 +229,7 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<RouterLink v-else to="/account/budget" class="budget-empty">예산을 설정하면 지출 진행률을 볼 수 있어요 →</RouterLink>
|
<RouterLink v-else to="/account/budget" class="budget-empty">예산을 설정하면 지출 진행률을 볼 수 있어요 →</RouterLink>
|
||||||
|
</template>
|
||||||
</div>
|
</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) {
|
async function changeStatus(m, status) {
|
||||||
if (status === m.status) return
|
if (status === m.status) return
|
||||||
try {
|
try {
|
||||||
@@ -114,7 +125,7 @@ onMounted(() => {
|
|||||||
<IconBtn icon="refresh" title="새로고침" size="sm" :disabled="loading" @click="load" />
|
<IconBtn icon="refresh" title="새로고침" size="sm" :disabled="loading" @click="load" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<p class="hint">가입한 회원의 역할·상태를 관리합니다. (관리자 전용)</p>
|
<p class="hint">가입한 회원의 역할·멤버십·상태를 관리합니다. (관리자 전용)</p>
|
||||||
|
|
||||||
<!-- 회원가입 허용 토글 -->
|
<!-- 회원가입 허용 토글 -->
|
||||||
<div class="signup-toggle" :class="{ off: !signupEnabled }">
|
<div class="signup-toggle" :class="{ off: !signupEnabled }">
|
||||||
@@ -139,6 +150,7 @@ onMounted(() => {
|
|||||||
<th>아이디 / 이름</th>
|
<th>아이디 / 이름</th>
|
||||||
<th class="c-email">이메일</th>
|
<th class="c-email">이메일</th>
|
||||||
<th class="c-role">역할</th>
|
<th class="c-role">역할</th>
|
||||||
|
<th class="c-plan">멤버십</th>
|
||||||
<th class="c-status">상태</th>
|
<th class="c-status">상태</th>
|
||||||
<th class="c-date">가입일</th>
|
<th class="c-date">가입일</th>
|
||||||
<th class="c-act"></th>
|
<th class="c-act"></th>
|
||||||
@@ -158,6 +170,16 @@ onMounted(() => {
|
|||||||
<option value="ADMIN">ADMIN</option>
|
<option value="ADMIN">ADMIN</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</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">
|
<td class="c-status">
|
||||||
<select
|
<select
|
||||||
:value="m.status" :disabled="m.id === myId"
|
:value="m.status" :disabled="m.id === myId"
|
||||||
@@ -289,6 +311,7 @@ h1 {
|
|||||||
width: 48px;
|
width: 48px;
|
||||||
}
|
}
|
||||||
.c-role,
|
.c-role,
|
||||||
|
.c-plan,
|
||||||
.c-status {
|
.c-status {
|
||||||
width: 90px;
|
width: 90px;
|
||||||
}
|
}
|
||||||
@@ -347,6 +370,13 @@ h1 {
|
|||||||
.status-sel.withdrawn {
|
.status-sel.withdrawn {
|
||||||
color: #c0392b;
|
color: #c0392b;
|
||||||
}
|
}
|
||||||
|
.plan-sel.premium {
|
||||||
|
color: #b8860b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.plan-sel.free {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
.msg {
|
.msg {
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { accountApi } from '@/api/accountApi'
|
import { accountApi } from '@/api/accountApi'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useDialog } from '@/composables/dialog'
|
import { useDialog } from '@/composables/dialog'
|
||||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||||
import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr'
|
import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr'
|
||||||
@@ -9,6 +10,8 @@ import { Capacitor } from '@capacitor/core'
|
|||||||
// @capacitor/camera 는 네이티브에서만 동적 로드 (웹은 file input 사용 — 정적 import 시 모바일 웹 청크 로드 실패 유발)
|
// @capacitor/camera 는 네이티브에서만 동적 로드 (웹은 file input 사용 — 정적 import 시 모바일 웹 청크 로드 실패 유발)
|
||||||
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const isPremium = computed(() => auth.isPremium) // 태그·OCR 등 유료 기능 노출 제어
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const year = ref(now.getFullYear())
|
const year = ref(now.getFullYear())
|
||||||
const month = ref(now.getMonth() + 1)
|
const month = ref(now.getMonth() + 1)
|
||||||
@@ -329,6 +332,11 @@ function toggleTag(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadTagOptions() {
|
async function loadTagOptions() {
|
||||||
|
// 태그는 유료 전용 — 무료 회원은 호출하지 않음(403/업그레이드 리다이렉트 방지)
|
||||||
|
if (!isPremium.value) {
|
||||||
|
tagOptions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
tagOptions.value = await accountApi.tags()
|
tagOptions.value = await accountApi.tags()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -832,7 +840,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 카드 결제 알림 자동인식 권한 안내 (네이티브, 미허용 시) -->
|
<!-- 카드 결제 알림 자동인식 권한 안내 (네이티브, 미허용 시) -->
|
||||||
<div v-if="notifNative && !notifEnabled" class="notif-banner">
|
<div v-if="isPremium && notifNative && !notifEnabled" class="notif-banner">
|
||||||
<span>💳 카드 결제 알림을 자동으로 가계부에 등록하려면 <b>알림 접근 권한</b>이 필요합니다.</span>
|
<span>💳 카드 결제 알림을 자동으로 가계부에 등록하려면 <b>알림 접근 권한</b>이 필요합니다.</span>
|
||||||
<button type="button" class="notif-btn" @click="openNotifSettings">설정 열기</button>
|
<button type="button" class="notif-btn" @click="openNotifSettings">설정 열기</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -959,6 +967,7 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
<div class="rb-actions">
|
<div class="rb-actions">
|
||||||
<button
|
<button
|
||||||
|
v-if="isPremium"
|
||||||
type="button" class="receipt-btn"
|
type="button" class="receipt-btn"
|
||||||
:disabled="submitting || ocrRunning" @click="pickReceipt"
|
:disabled="submitting || ocrRunning" @click="pickReceipt"
|
||||||
>📷 영수증 스캔</button>
|
>📷 영수증 스캔</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 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>
|
<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>
|
<span class="tag-label">태그</span>
|
||||||
<div class="tag-badges">
|
<div class="tag-badges">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -8,6 +8,8 @@ const router = useRouter()
|
|||||||
|
|
||||||
const u = computed(() => auth.user || {})
|
const u = computed(() => auth.user || {})
|
||||||
const roleLabel = computed(() => (u.value.role === 'ADMIN' ? '관리자' : '일반회원'))
|
const roleLabel = computed(() => (u.value.role === 'ADMIN' ? '관리자' : '일반회원'))
|
||||||
|
const isPremium = computed(() => auth.isPremium)
|
||||||
|
const planLabel = computed(() => (isPremium.value ? '유료 멤버십' : '무료'))
|
||||||
|
|
||||||
function goEdit() {
|
function goEdit() {
|
||||||
router.push('/settings/account/edit')
|
router.push('/settings/account/edit')
|
||||||
@@ -30,6 +32,13 @@ function goEdit() {
|
|||||||
<span class="k">권한</span>
|
<span class="k">권한</span>
|
||||||
<span class="v">{{ roleLabel }}</span>
|
<span class="v">{{ roleLabel }}</span>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
<button type="button" class="primary-btn" @click="goEdit">이름 변경</button>
|
<button type="button" class="primary-btn" @click="goEdit">이름 변경</button>
|
||||||
@@ -92,6 +101,15 @@ function goEdit() {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
word-break: break-all;
|
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 {
|
.primary-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
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 { useAuthStore } from '@/stores/auth'
|
||||||
import { exportBackup, importBackup } from '@/utils/backup'
|
import { exportBackup, importBackup } from '@/utils/backup'
|
||||||
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const isPremium = computed(() => auth.isPremium) // 데이터 백업/복구는 유료 전용
|
||||||
|
|
||||||
const exporting = ref(false)
|
const exporting = ref(false)
|
||||||
async function doExport() {
|
async function doExport() {
|
||||||
@@ -143,8 +146,9 @@ async function clearAppData() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 데이터 백업 -->
|
<!-- 데이터 백업 (유료 전용) -->
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
<template v-if="isPremium">
|
||||||
<button type="button" class="row row-btn" :disabled="exporting" @click="doExport">
|
<button type="button" class="row row-btn" :disabled="exporting" @click="doExport">
|
||||||
<div class="row-main">
|
<div class="row-main">
|
||||||
<span class="row-label">엑셀로 내보내기</span>
|
<span class="row-label">엑셀로 내보내기</span>
|
||||||
@@ -160,6 +164,16 @@ async function clearAppData() {
|
|||||||
<span class="row-value">{{ importing ? '복구 중…' : '가져오기' }}</span>
|
<span class="row-value">{{ importing ? '복구 중…' : '가져오기' }}</span>
|
||||||
</button>
|
</button>
|
||||||
<input ref="fileInput" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" class="hidden-file" @change="onImportFile" />
|
<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">엑셀 내보내기·가져오기는 유료 멤버십 기능입니다</span>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
|||||||
Reference in New Issue
Block a user