614 lines
24 KiB
Vue
614 lines
24 KiB
Vue
<template>
|
|
<section class="min-h-dvh bg-paper">
|
|
<header class="sticky top-0 z-10 flex h-14 items-center justify-between border-b border-line bg-white px-4">
|
|
<div class="flex items-center gap-3">
|
|
<button
|
|
v-if="view !== 'home'"
|
|
type="button"
|
|
class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper"
|
|
aria-label="뒤로"
|
|
@click="goBack"
|
|
>
|
|
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_left.svg" alt="">
|
|
</button>
|
|
<h1 class="text-lg font-bold">{{ title }}</h1>
|
|
</div>
|
|
|
|
<button
|
|
v-if="showAddButton"
|
|
type="button"
|
|
class="flex h-9 w-9 items-center justify-center rounded-md text-2xl leading-none text-brand-700 active:bg-paper"
|
|
aria-label="추가"
|
|
@click="openAddSheet"
|
|
>
|
|
+
|
|
</button>
|
|
</header>
|
|
|
|
<div v-if="view === 'home'">
|
|
<div class="border-b border-line bg-paper px-4 py-3 text-xs font-semibold text-muted">관리</div>
|
|
<div class="divide-y divide-line bg-white">
|
|
<button
|
|
v-for="item in menuItems"
|
|
:key="item.id"
|
|
type="button"
|
|
class="flex w-full items-center justify-between px-4 py-4 text-left active:bg-paper"
|
|
@click="open(item.id)"
|
|
>
|
|
<span>
|
|
<span class="block text-base font-semibold text-ink">{{ item.label }}</span>
|
|
<span class="mt-1 block text-xs text-muted">{{ item.description }}</span>
|
|
</span>
|
|
<img class="h-6 w-6 opacity-60" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
|
|
</button>
|
|
</div>
|
|
|
|
<div class="mt-3 border-y border-line bg-white">
|
|
<a
|
|
class="flex items-center justify-between px-4 py-4 active:bg-paper"
|
|
href="/api/export/transactions.csv"
|
|
>
|
|
<span>
|
|
<span class="block text-base font-semibold text-ink">CSV 내보내기</span>
|
|
<span class="mt-1 block text-xs text-muted">전체 거래 데이터를 파일로 저장합니다.</span>
|
|
</span>
|
|
<span class="text-xl text-muted">↓</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="view === 'account'" class="px-4 py-4">
|
|
<form class="space-y-4 border-y border-line bg-white px-4 py-4" @submit.prevent="saveAccount">
|
|
<div>
|
|
<label class="label" for="accountName">이름</label>
|
|
<input id="accountName" v-model="accountForm.name" class="field" maxlength="40" placeholder="이름" required>
|
|
</div>
|
|
<div>
|
|
<span class="label">이메일</span>
|
|
<p class="rounded-md bg-paper px-4 py-2.5 text-sm font-semibold text-muted">{{ session?.email }}</p>
|
|
</div>
|
|
<p v-if="accountMessage" class="text-sm font-semibold" :class="accountMessageType === 'error' ? 'text-red-600' : 'text-brand-700'">
|
|
{{ accountMessage }}
|
|
</p>
|
|
<button class="btn-primary w-full" type="submit" :disabled="accountPending">
|
|
{{ accountPending ? '저장 중...' : '계정 정보 저장' }}
|
|
</button>
|
|
</form>
|
|
|
|
<form class="mt-4 space-y-4 border-y border-line bg-white px-4 py-4" @submit.prevent="changePassword">
|
|
<div>
|
|
<label class="label" for="currentPassword">현재 비밀번호</label>
|
|
<input id="currentPassword" v-model="passwordForm.currentPassword" class="field" autocomplete="current-password" type="password" required>
|
|
</div>
|
|
<div>
|
|
<label class="label" for="nextPassword">새 비밀번호</label>
|
|
<input id="nextPassword" v-model="passwordForm.nextPassword" class="field" autocomplete="new-password" minlength="8" type="password" required>
|
|
</div>
|
|
<div>
|
|
<label class="label" for="nextPasswordConfirm">새 비밀번호 확인</label>
|
|
<input id="nextPasswordConfirm" v-model="passwordForm.confirmPassword" class="field" autocomplete="new-password" minlength="8" type="password" required>
|
|
</div>
|
|
<p v-if="passwordMessage" class="text-sm font-semibold" :class="passwordMessageType === 'error' ? 'text-red-600' : 'text-brand-700'">
|
|
{{ passwordMessage }}
|
|
</p>
|
|
<button class="btn-secondary w-full" type="submit" :disabled="passwordPending">
|
|
{{ passwordPending ? '변경 중...' : '비밀번호 변경' }}
|
|
</button>
|
|
</form>
|
|
|
|
<button type="button" class="btn-secondary mt-4 w-full border-red-200 text-red-600" @click="logout">
|
|
로그아웃
|
|
</button>
|
|
</div>
|
|
|
|
<div v-else-if="view === 'sharing'" class="px-4 py-4">
|
|
<form class="space-y-3 border-b border-line pb-5" @submit.prevent="createShareLink">
|
|
<input v-model="shareForm.name" class="field" placeholder="공유 이름">
|
|
<div>
|
|
<span class="label">조회 가능 기간</span>
|
|
<div class="grid grid-cols-3 gap-2">
|
|
<button
|
|
v-for="days in shareDayOptions"
|
|
:key="days"
|
|
type="button"
|
|
class="rounded-md border px-3 py-2 text-sm font-bold"
|
|
:class="shareForm.days === days ? 'border-red-500 bg-red-50 text-red-600' : 'border-line text-muted'"
|
|
@click="shareForm.days = days"
|
|
>
|
|
{{ days }}일
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<button class="btn-primary w-full" type="submit">공유 링크 만들기</button>
|
|
</form>
|
|
|
|
<div class="mt-4 divide-y divide-line border-y border-line bg-white">
|
|
<div v-for="link in shareLinks" :key="link.id" class="px-4 py-4">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<span class="min-w-0">
|
|
<span class="block truncate text-base font-semibold text-ink">{{ link.name }}</span>
|
|
<span class="mt-1 block text-xs text-muted">
|
|
{{ link.active ? '공유 중' : '비활성' }} · {{ dateLabel(link.expiresAt) }}까지
|
|
</span>
|
|
<span class="mt-1 block truncate text-xs text-muted">{{ shareUrl(link.token) }}</span>
|
|
</span>
|
|
<span class="shrink-0 rounded-full px-2 py-1 text-xs font-bold" :class="link.active ? 'bg-brand-50 text-brand-700' : 'bg-paper text-muted'">
|
|
{{ link.active ? 'ON' : 'OFF' }}
|
|
</span>
|
|
</div>
|
|
<div class="mt-3 grid grid-cols-2 gap-2">
|
|
<button type="button" class="btn-secondary" :disabled="!link.active" @click="copyShareUrl(link.token)">복사</button>
|
|
<button type="button" class="btn-secondary border-red-200 text-red-600" :disabled="!link.active" @click="revokeShareLink(link.id)">삭제</button>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="!shareLinks.length" class="px-4 py-12 text-center text-muted">
|
|
만든 공유 링크가 없습니다.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="view === 'assets'" class="px-4 py-4">
|
|
<form class="space-y-3 border-b border-line pb-5" @submit.prevent="createAsset">
|
|
<input v-model="assetForm.name" class="field" placeholder="자산 이름" required>
|
|
<div>
|
|
<span class="label">잔액 계산 방식</span>
|
|
<div class="space-y-2">
|
|
<label class="flex gap-3 rounded-md border border-line bg-white p-3">
|
|
<input v-model="assetForm.balanceType" type="radio" value="cumulative">
|
|
<span>
|
|
<strong class="block">계속 누적</strong>
|
|
<small class="text-muted">이전 달의 잔액을 계속 이어갑니다.</small>
|
|
</span>
|
|
</label>
|
|
<label class="flex gap-3 rounded-md border border-line bg-white p-3">
|
|
<input v-model="assetForm.balanceType" type="radio" value="monthly_reset">
|
|
<span>
|
|
<strong class="block">매월 새로 시작</strong>
|
|
<small class="text-muted">매월 1일 잔액을 0원부터 계산합니다.</small>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<input v-model.number="assetForm.initialBalance" class="field text-right" inputmode="numeric" type="number" placeholder="최초 잔액">
|
|
<button class="btn-primary w-full" type="submit">자산 추가</button>
|
|
</form>
|
|
|
|
<div class="mt-4 divide-y divide-line border-y border-line bg-white">
|
|
<div v-for="asset in assets" :key="asset.id" class="flex items-center justify-between gap-3 py-3">
|
|
<div>
|
|
<p class="font-semibold">{{ asset.name }}</p>
|
|
<p class="mt-1 text-xs text-muted">
|
|
{{ asset.balanceType === 'cumulative' ? '계속 누적' : '매월 새로 시작' }} · {{ asset.active ? '활성' : '비활성' }}
|
|
</p>
|
|
</div>
|
|
<ToggleSwitch :model-value="asset.active" @update:model-value="toggleAsset(asset, $event)" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="view === 'categories'" class="py-4">
|
|
<form v-if="selectedCategory" class="px-4" @submit.prevent="saveCategory">
|
|
<section class="space-y-5 border-y border-line bg-white px-4 py-4">
|
|
<div class="grid grid-cols-[4.5rem_1fr] items-center gap-3">
|
|
<label class="text-sm font-semibold text-muted" for="categoryName">이름</label>
|
|
<input id="categoryName" v-model="categoryEditForm.name" class="field border-0 bg-transparent font-semibold shadow-none focus:ring-0" required>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-[4.5rem_1fr] items-start gap-3">
|
|
<span class="pt-2 text-sm font-semibold text-muted">아이콘</span>
|
|
<div class="grid grid-cols-5 gap-2 sm:grid-cols-7">
|
|
<button
|
|
v-for="option in categoryIconOptions"
|
|
:key="option.value"
|
|
type="button"
|
|
class="flex h-10 items-center justify-center rounded-md border text-sm font-extrabold"
|
|
:class="categoryEditForm.icon === option.value ? 'border-brand-700 bg-brand-50 text-brand-700' : 'border-line text-muted'"
|
|
@click="categoryEditForm.icon = option.value"
|
|
>
|
|
{{ option.glyph }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-[4.5rem_1fr] items-start gap-3">
|
|
<span class="pt-2 text-sm font-semibold text-muted">색상</span>
|
|
<div class="flex flex-wrap gap-2">
|
|
<button
|
|
v-for="color in categoryColorOptions"
|
|
:key="color"
|
|
type="button"
|
|
class="h-9 w-9 rounded-full border-2"
|
|
:class="categoryEditForm.color === color ? 'border-ink' : 'border-transparent'"
|
|
:style="{ backgroundColor: color }"
|
|
:aria-label="color"
|
|
@click="categoryEditForm.color = color"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-[4.5rem_1fr] items-center gap-3">
|
|
<span class="text-sm font-semibold text-muted">유형</span>
|
|
<p class="font-semibold">{{ selectedCategory.type === 'income' ? '수입' : '지출' }}</p>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-[4.5rem_1fr] items-center gap-3">
|
|
<span class="text-sm font-semibold text-muted">위치</span>
|
|
<p class="font-semibold">{{ selectedCategory.parent?.name || '상위 카테고리' }}</p>
|
|
</div>
|
|
</section>
|
|
|
|
<button class="btn-primary mt-5 w-full" type="submit">저장하기</button>
|
|
</form>
|
|
|
|
<div v-else-if="!selectedParentCategory" class="px-4">
|
|
<div class="segmented">
|
|
<button
|
|
type="button"
|
|
class="segmented-button"
|
|
:class="{ 'segmented-button-active': categoryType === 'income' }"
|
|
@click="categoryType = 'income'"
|
|
>
|
|
수입
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="segmented-button"
|
|
:class="{ 'segmented-button-active': categoryType === 'expense' }"
|
|
@click="categoryType = 'expense'"
|
|
>
|
|
지출
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="!selectedCategory" class="mt-4 divide-y divide-line border-y border-line bg-white">
|
|
<button
|
|
v-if="selectedParentCategory"
|
|
type="button"
|
|
class="flex w-full items-center justify-between px-4 py-4 text-left active:bg-paper"
|
|
@click="openCategoryDetail(selectedParentCategory)"
|
|
>
|
|
<span class="flex min-w-0 items-center gap-3">
|
|
<span
|
|
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-sm font-extrabold"
|
|
:style="categoryStyle(selectedParentCategory)"
|
|
>
|
|
{{ categoryIcon(selectedParentCategory) }}
|
|
</span>
|
|
<span class="min-w-0">
|
|
<span class="block truncate text-base font-semibold text-ink">상위 카테고리 설정</span>
|
|
<span class="mt-1 block truncate text-xs text-muted">{{ selectedParentCategory.name }}</span>
|
|
</span>
|
|
</span>
|
|
<img class="h-6 w-6 opacity-60" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
|
|
</button>
|
|
|
|
<button
|
|
v-for="category in visibleCategories"
|
|
:key="category.id"
|
|
type="button"
|
|
class="flex w-full items-center justify-between px-4 py-4 text-left active:bg-paper"
|
|
@click="selectCategory(category)"
|
|
>
|
|
<span class="flex min-w-0 items-center gap-3">
|
|
<span
|
|
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-sm font-extrabold"
|
|
:style="categoryStyle(category)"
|
|
>
|
|
{{ categoryIcon(category) }}
|
|
</span>
|
|
<span class="min-w-0">
|
|
<span class="block truncate text-base font-semibold text-ink">{{ category.name }}</span>
|
|
<span class="mt-1 block truncate text-xs text-muted">{{ categoryDescription(category) }}</span>
|
|
</span>
|
|
</span>
|
|
<img class="h-6 w-6 opacity-60" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
|
|
</button>
|
|
|
|
<p v-if="!visibleCategories.length" class="px-4 py-12 text-center text-muted">
|
|
{{ selectedParentCategory ? '하위 카테고리가 없습니다.' : `${categoryTypeLabel} 상위 카테고리가 없습니다.` }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<Teleport to="body">
|
|
<Transition enter-active-class="transition duration-200 ease-out" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100" leave-to-class="opacity-0">
|
|
<div v-if="addSheetOpen" class="fixed inset-0 z-40 bg-ink/35" @click="addSheetOpen = false" />
|
|
</Transition>
|
|
<Transition enter-active-class="transition duration-200 ease-out" enter-from-class="translate-y-full" enter-to-class="translate-y-0" leave-active-class="transition duration-150 ease-in" leave-from-class="translate-y-0" leave-to-class="translate-y-full">
|
|
<section v-if="addSheetOpen" class="fixed inset-x-0 bottom-0 z-50 mx-auto max-w-lg rounded-t-2xl bg-white shadow-2xl">
|
|
<form class="px-4 pb-6 pt-2" @submit.prevent="createCategory">
|
|
<div class="mx-auto mb-3 h-1 w-10 rounded-full bg-line" />
|
|
<div class="mb-5 flex items-center justify-between">
|
|
<h2 class="text-lg font-bold">{{ addSheetTitle }}</h2>
|
|
<button type="button" class="text-sm font-semibold text-muted" @click="addSheetOpen = false">닫기</button>
|
|
</div>
|
|
<input
|
|
v-model="categoryName"
|
|
class="field"
|
|
:placeholder="selectedParentCategory ? '하위 카테고리 이름' : '상위 카테고리 이름'"
|
|
required
|
|
>
|
|
<button class="btn-primary mt-5 w-full" type="submit">추가하기</button>
|
|
</form>
|
|
</section>
|
|
</Transition>
|
|
</Teleport>
|
|
</section>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { format } from 'date-fns'
|
|
import { CATEGORY_COLOR_OPTIONS, CATEGORY_ICON_OPTIONS, categoryColor, categoryGlyph } from '~/utils/categoryPresentation'
|
|
|
|
type SettingsView = 'home' | 'account' | 'sharing' | 'assets' | 'categories'
|
|
type CategoryType = 'income' | 'expense'
|
|
|
|
const view = ref<SettingsView>('home')
|
|
const session = useState<{ id: string; email: string; name: string } | null>('session')
|
|
const categoryType = ref<CategoryType>('expense')
|
|
const selectedParentId = ref('')
|
|
const selectedCategoryId = ref('')
|
|
const addSheetOpen = ref(false)
|
|
const categoryName = ref('')
|
|
const { data: assetData, refresh: refreshAssets } = await useFetch('/api/assets', { query: { includeInactive: true } })
|
|
const { data: categoryData, refresh: refreshCategories } = await useFetch('/api/categories', { query: { includeInactive: true } })
|
|
const { data: shareLinkData, refresh: refreshShareLinks } = await useFetch('/api/share-links')
|
|
|
|
const menuItems = [
|
|
{ id: 'account', label: '계정 관리', description: '로그인 계정 확인과 로그아웃' },
|
|
{ id: 'sharing', label: '공유 링크 관리', description: '읽기 전용 공유 링크 생성과 삭제' },
|
|
{ id: 'assets', label: '자산 관리', description: '자산 추가, 잔액 계산 방식, 활성 상태' },
|
|
{ id: 'categories', label: '카테고리 관리', description: '수입/지출 상위 분류와 하위 분류' }
|
|
] as const
|
|
|
|
const assetForm = reactive({
|
|
name: '',
|
|
balanceType: 'cumulative',
|
|
initialBalance: 0
|
|
})
|
|
const shareDayOptions = [1, 7, 30]
|
|
const shareForm = reactive({
|
|
name: '',
|
|
days: 7
|
|
})
|
|
const accountPending = ref(false)
|
|
const passwordPending = ref(false)
|
|
const accountMessage = ref('')
|
|
const passwordMessage = ref('')
|
|
const accountMessageType = ref<'ok' | 'error'>('ok')
|
|
const passwordMessageType = ref<'ok' | 'error'>('ok')
|
|
const accountForm = reactive({
|
|
name: session.value?.name || ''
|
|
})
|
|
const passwordForm = reactive({
|
|
currentPassword: '',
|
|
nextPassword: '',
|
|
confirmPassword: ''
|
|
})
|
|
const categoryEditForm = reactive({
|
|
name: '',
|
|
icon: 'circle',
|
|
color: '#6b7673'
|
|
})
|
|
const categoryIconOptions = CATEGORY_ICON_OPTIONS
|
|
const categoryColorOptions = CATEGORY_COLOR_OPTIONS
|
|
|
|
const title = computed(() => {
|
|
if (view.value === 'account') return '계정 관리'
|
|
if (view.value === 'sharing') return '공유 링크 관리'
|
|
if (view.value === 'assets') return '자산 관리'
|
|
if (selectedCategory.value) return '카테고리 정보'
|
|
if (selectedParentCategory.value) return selectedParentCategory.value.name
|
|
if (view.value === 'categories') return '카테고리 관리'
|
|
return '설정'
|
|
})
|
|
const assets = computed(() => assetData.value || [])
|
|
const categories = computed(() => categoryData.value || [])
|
|
const shareLinks = computed(() => shareLinkData.value || [])
|
|
const selectedParentCategory = computed(() => categories.value.find((category: any) => category.id === selectedParentId.value))
|
|
const selectedCategory = computed(() => categories.value.find((category: any) => category.id === selectedCategoryId.value))
|
|
const categoryTypeLabel = computed(() => categoryType.value === 'income' ? '수입' : '지출')
|
|
const visibleCategories = computed(() => {
|
|
if (selectedParentId.value) {
|
|
return categories.value
|
|
.filter((category: any) => category.parentId === selectedParentId.value)
|
|
.sort((a: any, b: any) => a.sortOrder - b.sortOrder)
|
|
}
|
|
|
|
return categories.value
|
|
.filter((category: any) => category.type === categoryType.value && !category.parentId)
|
|
.sort((a: any, b: any) => a.sortOrder - b.sortOrder)
|
|
})
|
|
const showAddButton = computed(() => view.value === 'categories')
|
|
const addSheetTitle = computed(() => selectedParentCategory.value ? `${selectedParentCategory.value.name} 하위 카테고리 추가` : `${categoryTypeLabel.value} 상위 카테고리 추가`)
|
|
|
|
watch(categoryType, () => {
|
|
selectedParentId.value = ''
|
|
selectedCategoryId.value = ''
|
|
})
|
|
|
|
watch(selectedCategory, (category: any) => {
|
|
if (!category) return
|
|
Object.assign(categoryEditForm, {
|
|
name: category.name,
|
|
icon: category.icon || 'circle',
|
|
color: category.color || '#6b7673'
|
|
})
|
|
})
|
|
|
|
function open(nextView: SettingsView) {
|
|
view.value = nextView
|
|
selectedParentId.value = ''
|
|
selectedCategoryId.value = ''
|
|
if (nextView === 'account') {
|
|
accountForm.name = session.value?.name || ''
|
|
accountMessage.value = ''
|
|
passwordMessage.value = ''
|
|
}
|
|
}
|
|
|
|
function goBack() {
|
|
if (selectedCategoryId.value) {
|
|
selectedCategoryId.value = ''
|
|
return
|
|
}
|
|
if (selectedParentId.value) {
|
|
selectedParentId.value = ''
|
|
return
|
|
}
|
|
view.value = 'home'
|
|
}
|
|
|
|
function openAddSheet() {
|
|
categoryName.value = ''
|
|
addSheetOpen.value = true
|
|
}
|
|
|
|
function selectCategory(category: any) {
|
|
if (selectedParentId.value) {
|
|
openCategoryDetail(category)
|
|
return
|
|
}
|
|
selectedParentId.value = category.id
|
|
}
|
|
|
|
function openCategoryDetail(category: any) {
|
|
selectedCategoryId.value = category.id
|
|
}
|
|
|
|
function categoryDescription(category: any) {
|
|
if (selectedParentId.value) return '하위 카테고리'
|
|
const childCount = categories.value.filter((item: any) => item.parentId === category.id).length
|
|
return `${childCount}개 하위 카테고리`
|
|
}
|
|
|
|
function categoryIcon(category: any) {
|
|
return categoryGlyph(category.icon)
|
|
}
|
|
|
|
function categoryStyle(category: any) {
|
|
const color = categoryColor(category.color)
|
|
return {
|
|
color,
|
|
backgroundColor: `${color}14`
|
|
}
|
|
}
|
|
|
|
async function createAsset() {
|
|
await $fetch('/api/assets', { method: 'POST', body: assetForm })
|
|
Object.assign(assetForm, { name: '', balanceType: 'cumulative', initialBalance: 0 })
|
|
await refreshAssets()
|
|
}
|
|
|
|
async function toggleAsset(asset: any, active: boolean) {
|
|
await $fetch(`/api/assets/${asset.id}`, { method: 'PATCH', body: { active } })
|
|
await refreshAssets()
|
|
}
|
|
|
|
async function createCategory() {
|
|
await $fetch('/api/categories', {
|
|
method: 'POST',
|
|
body: {
|
|
type: categoryType.value,
|
|
parentId: selectedParentId.value || '',
|
|
name: categoryName.value,
|
|
icon: selectedParentCategory.value?.icon || 'circle',
|
|
color: selectedParentCategory.value?.color || '#6b7673'
|
|
}
|
|
})
|
|
categoryName.value = ''
|
|
addSheetOpen.value = false
|
|
await refreshCategories()
|
|
}
|
|
|
|
async function saveCategory() {
|
|
if (!selectedCategory.value) return
|
|
await $fetch(`/api/categories/${selectedCategory.value.id}`, {
|
|
method: 'PATCH',
|
|
body: categoryEditForm
|
|
})
|
|
await refreshCategories()
|
|
selectedCategoryId.value = ''
|
|
}
|
|
|
|
async function createShareLink() {
|
|
await $fetch('/api/share-links', {
|
|
method: 'POST',
|
|
body: shareForm
|
|
})
|
|
Object.assign(shareForm, { name: '', days: 7 })
|
|
await refreshShareLinks()
|
|
}
|
|
|
|
async function revokeShareLink(id: string) {
|
|
await $fetch(`/api/share-links/${id}`, { method: 'DELETE' })
|
|
await refreshShareLinks()
|
|
}
|
|
|
|
async function copyShareUrl(token: string) {
|
|
if (!import.meta.client) return
|
|
await navigator.clipboard?.writeText(shareUrl(token))
|
|
}
|
|
|
|
async function saveAccount() {
|
|
accountPending.value = true
|
|
accountMessage.value = ''
|
|
try {
|
|
const updated = await $fetch<{ id: string; email: string; name: string }>('/api/auth/me', {
|
|
method: 'PATCH',
|
|
body: accountForm
|
|
})
|
|
session.value = updated
|
|
accountMessageType.value = 'ok'
|
|
accountMessage.value = '계정 정보를 저장했습니다.'
|
|
} catch {
|
|
accountMessageType.value = 'error'
|
|
accountMessage.value = '계정 정보를 저장하지 못했습니다.'
|
|
} finally {
|
|
accountPending.value = false
|
|
}
|
|
}
|
|
|
|
async function changePassword() {
|
|
passwordPending.value = true
|
|
passwordMessage.value = ''
|
|
try {
|
|
if (passwordForm.nextPassword !== passwordForm.confirmPassword) {
|
|
throw new Error('password mismatch')
|
|
}
|
|
await $fetch('/api/auth/password', {
|
|
method: 'POST',
|
|
body: {
|
|
currentPassword: passwordForm.currentPassword,
|
|
nextPassword: passwordForm.nextPassword
|
|
}
|
|
})
|
|
Object.assign(passwordForm, { currentPassword: '', nextPassword: '', confirmPassword: '' })
|
|
passwordMessageType.value = 'ok'
|
|
passwordMessage.value = '비밀번호를 변경했습니다.'
|
|
} catch {
|
|
passwordMessageType.value = 'error'
|
|
passwordMessage.value = '비밀번호를 확인해 주세요.'
|
|
} finally {
|
|
passwordPending.value = false
|
|
}
|
|
}
|
|
|
|
function shareUrl(token: string) {
|
|
if (!import.meta.client) return `/share/${token}`
|
|
return `${window.location.origin}/share/${token}`
|
|
}
|
|
|
|
function dateLabel(value: string) {
|
|
return format(new Date(value), 'yyyy.MM.dd')
|
|
}
|
|
|
|
async function logout() {
|
|
await $fetch('/api/auth/logout', { method: 'POST' })
|
|
session.value = null
|
|
await navigateTo('/login')
|
|
}
|
|
</script>
|