Files
money.sori.studio/pages/settings.vue
2026-08-14 13:27:57 +09:00

923 lines
36 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-brand-700 active:bg-paper"
aria-label="추가"
@click="openAddSheet"
>
<span class="nav-icon h-6 w-6" v-html="addIcon" />
</button>
</header>
<div v-if="view === 'home'" class="md:grid md:grid-cols-2 md:gap-4 md:p-5 xl:grid-cols-3">
<section
v-for="section in menuSections"
:key="section.id"
class="md:overflow-hidden md:rounded-md md:border md:border-line md:bg-white md:shadow-sm"
>
<div class="border-b border-line bg-paper px-4 py-3 md:bg-white">
<h2 class="text-sm font-bold text-ink">{{ section.title }}</h2>
<p class="mt-1 text-xs font-semibold text-muted">{{ section.description }}</p>
</div>
<div class="divide-y divide-line bg-white">
<button
v-for="item in section.items"
:key="item.id"
type="button"
class="flex w-full items-center justify-between gap-3 px-4 py-4 text-left active:bg-paper"
@click="open(item.id)"
>
<span class="min-w-0">
<span class="block text-sm font-bold text-ink">{{ item.label }}</span>
<span class="mt-1 block text-xs font-semibold text-muted">{{ item.description }}</span>
</span>
<img class="h-6 w-6 shrink-0 opacity-60" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
</button>
</div>
</section>
<section class="mt-3 border-y border-line bg-white md:mt-0 md:overflow-hidden md:rounded-md md:border md:border-line md:shadow-sm">
<div class="border-b border-line bg-paper px-4 py-3 md:bg-white">
<h2 class="text-sm font-bold text-ink">데이터</h2>
<p class="mt-1 text-xs font-semibold text-muted">백업과 외부 확인용 기능입니다.</p>
</div>
<a
class="flex items-center justify-between gap-3 px-4 py-4 active:bg-paper"
href="/api/export/transactions.csv"
>
<span class="min-w-0">
<span class="block text-sm font-bold text-ink">CSV 내보내기</span>
<span class="mt-1 block text-xs font-semibold text-muted">전체 거래 데이터를 파일로 저장합니다.</span>
</span>
<span class="shrink-0 text-xl text-muted">↓</span>
</a>
</section>
</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>
<EmptyState
v-if="!shareLinks.length"
title="만든 공유 링크가 없습니다."
description="기간을 정해 읽기 전용으로 보여줄 링크를 만들 수 있습니다."
mark="↗"
/>
</div>
</div>
<div v-else-if="view === 'preferences'" class="px-4 py-4">
<form class="space-y-5 border-y border-line bg-white px-4 py-4 md:rounded-md md:border" @submit.prevent="savePreferences">
<div>
<span class="label">주 화폐</span>
<div class="grid grid-cols-3 gap-2">
<button
v-for="currency in currencyOptions"
:key="currency.value"
type="button"
class="rounded-md border px-3 py-2.5 text-sm font-bold"
:class="preferenceForm.primaryCurrency === currency.value ? 'border-red-500 bg-red-50 text-red-600' : 'border-line text-muted'"
@click="preferenceForm.primaryCurrency = currency.value"
>
{{ currency.label }}
</button>
</div>
</div>
<div>
<label class="label" for="monthStartDay">월 시작일</label>
<select id="monthStartDay" v-model.number="preferenceForm.monthStartDay" class="field">
<option v-for="day in monthStartOptions" :key="day" :value="day">{{ day }}일</option>
</select>
</div>
<div>
<span class="label">주간 시작일</span>
<div class="grid grid-cols-4 gap-2 sm:grid-cols-7">
<button
v-for="day in weekStartOptions"
:key="day.value"
type="button"
class="rounded-md border px-2 py-2.5 text-xs font-bold"
:class="preferenceForm.weekStartDay === day.value ? 'border-red-500 bg-red-50 text-red-600' : 'border-line text-muted'"
@click="preferenceForm.weekStartDay = day.value"
>
{{ day.label }}
</button>
</div>
</div>
<p v-if="preferenceMessage" class="text-sm font-semibold" :class="preferenceMessageType === 'error' ? 'text-red-600' : 'text-brand-700'">
{{ preferenceMessage }}
</p>
<button class="btn-primary w-full" type="submit" :disabled="preferencePending">
{{ preferencePending ? '저장 중...' : '기본 설정 저장' }}
</button>
</form>
</div>
<div v-else-if="view === 'assets'" class="py-4">
<form v-if="selectedAsset" class="px-4" @submit.prevent="saveAsset">
<section class="space-y-5 border-y border-line bg-white px-4 py-4">
<div class="grid grid-cols-[5rem_1fr] items-center gap-3">
<label class="text-sm font-semibold text-muted" for="assetName">이름</label>
<input id="assetName" v-model="assetEditForm.name" class="field border-0 bg-transparent font-semibold shadow-none focus:ring-0" required>
</div>
<div class="grid grid-cols-[5rem_1fr] items-start gap-3">
<span class="pt-2 text-sm font-semibold text-muted">계산 방식</span>
<div class="grid gap-2">
<label class="flex gap-3 rounded-md border p-3" :class="assetEditForm.balanceType === 'cumulative' ? 'border-brand-600 bg-brand-50' : 'border-line bg-white'">
<input v-model="assetEditForm.balanceType" type="radio" value="cumulative">
<span>
<strong class="block text-sm">계속 누적</strong>
<small class="text-muted">이전 달 잔액을 이어갑니다.</small>
</span>
</label>
<label class="flex gap-3 rounded-md border p-3" :class="assetEditForm.balanceType === 'monthly_reset' ? 'border-brand-600 bg-brand-50' : 'border-line bg-white'">
<input v-model="assetEditForm.balanceType" type="radio" value="monthly_reset">
<span>
<strong class="block text-sm">매월 새로 시작</strong>
<small class="text-muted">조회 월 기준으로 계산합니다.</small>
</span>
</label>
</div>
</div>
<div class="grid grid-cols-[5rem_1fr] items-center gap-3">
<label class="text-sm font-semibold text-muted" for="assetInitialBalance">최초 잔액</label>
<input id="assetInitialBalance" v-model.number="assetEditForm.initialBalance" class="field border-0 bg-transparent text-right font-semibold shadow-none focus:ring-0" inputmode="numeric" type="number">
</div>
</section>
<p v-if="assetMessage" class="mt-3 text-sm font-semibold text-red-600">{{ assetMessage }}</p>
<div class="mt-5 grid grid-cols-[2fr_1fr] gap-2">
<button class="btn-primary" type="submit">저장하기</button>
<button class="btn-secondary border-red-200 text-red-600" type="button" @click="deleteAsset">삭제</button>
</div>
</form>
<div v-else class="divide-y divide-line border-y border-line bg-white">
<button
v-for="asset in assets"
:key="asset.id"
type="button"
class="flex w-full items-center justify-between gap-3 px-4 py-4 text-left active:bg-paper"
@click="openAssetDetail(asset)"
>
<span class="min-w-0">
<span class="block truncate text-base font-semibold text-ink">{{ asset.name }}</span>
<span class="mt-1 block truncate text-xs text-muted">
{{ asset.balanceType === 'cumulative' ? '계속 누적' : '매월 새로 시작' }}
</span>
</span>
<img class="h-6 w-6 opacity-60" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
</button>
<EmptyState
v-if="!assets.length"
title="등록된 자산이 없습니다."
description="오른쪽 위 추가 버튼으로 자산을 등록해 주세요."
mark="₩"
/>
</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>
<p v-if="categoryMessage" class="mt-3 text-sm font-semibold text-red-600">{{ categoryMessage }}</p>
<div class="mt-5 grid grid-cols-[2fr_1fr] gap-2">
<button class="btn-primary" type="submit">저장하기</button>
<button class="btn-secondary border-red-200 text-red-600" type="button" @click="deleteCategory">삭제</button>
</div>
</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>
<EmptyState
v-if="!visibleCategories.length"
:title="selectedParentCategory ? '하위 카테고리가 없습니다.' : `${categoryTypeLabel} 상위 카테고리가 없습니다.`"
description="오른쪽 위 추가 버튼으로 필요한 분류를 추가해 주세요."
mark="+"
/>
</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="closeAddSheet" />
</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="submitAddSheet">
<div class="mx-auto mb-3 h-1 w-10 rounded-full bg-line" />
<div class="mb-5">
<h2 class="text-lg font-bold">{{ addSheetTitle }}</h2>
</div>
<template v-if="view === 'assets'">
<div class="space-y-4">
<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="최초 잔액">
</div>
</template>
<input
v-else
v-model="categoryName"
class="field"
:placeholder="selectedParentCategory ? '하위 카테고리 이름' : '상위 카테고리 이름'"
required
>
<p v-if="addSheetError" class="mt-3 text-sm font-semibold text-red-600">{{ addSheetError }}</p>
<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 addIconRaw from '~/assets/icons/add.svg?raw'
import { CATEGORY_COLOR_OPTIONS, CATEGORY_ICON_OPTIONS, categoryColor, categoryGlyph } from '~/utils/categoryPresentation'
type SettingsView = 'home' | 'account' | 'sharing' | 'preferences' | 'assets' | 'categories'
type CategoryType = 'income' | 'expense'
const addIcon = addIconRaw
.replace(/fill="[^"]*"/g, 'fill="currentColor"')
.replace(/height="[^"]*"/, 'height="100%"')
.replace(/width="[^"]*"/, 'width="100%"')
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 selectedAssetId = ref('')
const addSheetOpen = ref(false)
const addSheetError = ref('')
const categoryName = ref('')
const { data: assetData, refresh: refreshAssets } = await useFetch('/api/assets')
const { data: categoryData, refresh: refreshCategories } = await useFetch('/api/categories')
const { data: shareLinkData, refresh: refreshShareLinks } = await useFetch('/api/share-links')
const { data: preferenceData, refresh: refreshPreferences } = await useFetch('/api/user-settings')
const menuSections = [
{
id: 'account',
title: '계정',
description: '로그인 정보와 공유 권한을 관리합니다.',
items: [
{ id: 'account', label: '계정 관리', description: '이름, 비밀번호, 로그아웃' },
{ id: 'sharing', label: '공유 링크 관리', description: '읽기 전용 링크 생성과 삭제' }
]
},
{
id: 'money',
title: '가계부',
description: '입력할 때 사용하는 기본 항목입니다.',
items: [
{ id: 'preferences', label: '기본 설정', description: '화폐, 월 시작일, 주간 시작일' },
{ id: 'assets', label: '자산 관리', description: '자산 추가, 수정, 삭제' },
{ id: 'categories', label: '카테고리 관리', description: '수입/지출 상위 분류와 하위 분류' }
]
}
] as const
const assetForm = reactive({
name: '',
balanceType: 'cumulative',
initialBalance: 0
})
const assetEditForm = reactive({
name: '',
balanceType: 'cumulative',
initialBalance: 0
})
const assetMessage = ref('')
const shareDayOptions = [1, 7, 30]
const shareForm = reactive({
name: '',
days: 7
})
const accountPending = ref(false)
const passwordPending = ref(false)
const preferencePending = ref(false)
const accountMessage = ref('')
const passwordMessage = ref('')
const preferenceMessage = ref('')
const accountMessageType = ref<'ok' | 'error'>('ok')
const passwordMessageType = ref<'ok' | 'error'>('ok')
const preferenceMessageType = ref<'ok' | 'error'>('ok')
const accountForm = reactive({
name: session.value?.name || ''
})
const passwordForm = reactive({
currentPassword: '',
nextPassword: '',
confirmPassword: ''
})
const preferenceForm = reactive({
primaryCurrency: 'KRW',
monthStartDay: 1,
weekStartDay: 0
})
const currencyOptions = [
{ value: 'KRW', label: 'KRW' },
{ value: 'USD', label: 'USD' },
{ value: 'JPY', label: 'JPY' }
]
const monthStartOptions = Array.from({ length: 28 }, (_, index) => index + 1)
const weekStartOptions = ['일', '월', '화', '수', '목', '금', '토'].map((label, value) => ({ label, value }))
const categoryEditForm = reactive({
name: '',
icon: 'circle',
color: '#6b7673'
})
const categoryMessage = ref('')
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 === 'preferences') return '기본 설정'
if (selectedAsset.value) 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 selectedAsset = computed(() => assets.value.find((asset: any) => asset.id === selectedAssetId.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 === 'assets' && !selectedAsset.value) || (view.value === 'categories' && !selectedCategory.value))
const addSheetTitle = computed(() => {
if (view.value === 'assets') return '자산 추가'
return 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'
})
categoryMessage.value = ''
})
watch(selectedAsset, (asset: any) => {
if (!asset) return
Object.assign(assetEditForm, {
name: asset.name,
balanceType: asset.balanceType,
initialBalance: asset.initialBalance || 0
})
assetMessage.value = ''
})
watch(preferenceData, (value: any) => {
if (!value) return
Object.assign(preferenceForm, {
primaryCurrency: value.primaryCurrency || 'KRW',
monthStartDay: value.monthStartDay || 1,
weekStartDay: value.weekStartDay ?? 0
})
}, { immediate: true })
function open(nextView: SettingsView) {
view.value = nextView
selectedParentId.value = ''
selectedCategoryId.value = ''
selectedAssetId.value = ''
assetMessage.value = ''
categoryMessage.value = ''
if (nextView === 'account') {
accountForm.name = session.value?.name || ''
accountMessage.value = ''
passwordMessage.value = ''
}
if (nextView === 'preferences') {
preferenceMessage.value = ''
}
}
function goBack() {
if (selectedAssetId.value) {
selectedAssetId.value = ''
return
}
if (selectedCategoryId.value) {
selectedCategoryId.value = ''
return
}
if (selectedParentId.value) {
selectedParentId.value = ''
return
}
view.value = 'home'
}
function openAddSheet() {
categoryName.value = ''
addSheetError.value = ''
Object.assign(assetForm, { name: '', balanceType: 'cumulative', initialBalance: 0 })
addSheetOpen.value = true
}
function closeAddSheet() {
addSheetOpen.value = false
addSheetError.value = ''
}
function submitAddSheet() {
if (view.value === 'assets') return createAsset()
return createCategory()
}
function openAssetDetail(asset: any) {
selectedAssetId.value = asset.id
}
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() {
addSheetError.value = ''
if (!assetForm.name.trim()) {
addSheetError.value = '자산 이름을 입력해 주세요.'
return
}
try {
await $fetch('/api/assets', { method: 'POST', body: assetForm })
Object.assign(assetForm, { name: '', balanceType: 'cumulative', initialBalance: 0 })
closeAddSheet()
await refreshAssets()
} catch {
addSheetError.value = '자산을 추가하지 못했습니다.'
}
}
async function saveAsset() {
if (!selectedAsset.value) return
await $fetch(`/api/assets/${selectedAsset.value.id}`, {
method: 'PATCH',
body: assetEditForm
})
await refreshAssets()
selectedAssetId.value = ''
}
async function deleteAsset() {
if (!selectedAsset.value) return
assetMessage.value = ''
try {
await $fetch(`/api/assets/${selectedAsset.value.id}`, { method: 'DELETE' })
await refreshAssets()
selectedAssetId.value = ''
} catch (err: any) {
const status = err?.statusCode || err?.response?.status
assetMessage.value = status === 409
? '거래가 연결된 자산은 삭제할 수 없습니다.'
: '자산을 삭제하지 못했습니다.'
}
}
async function createCategory() {
addSheetError.value = ''
if (!categoryName.value.trim()) {
addSheetError.value = '카테고리 이름을 입력해 주세요.'
return
}
try {
await $fetch('/api/categories', {
method: 'POST',
body: {
type: categoryType.value,
parentId: selectedParentId.value || undefined,
name: categoryName.value,
icon: selectedParentCategory.value?.icon || (categoryType.value === 'income' ? 'salary' : 'shopping'),
color: selectedParentCategory.value?.color || (categoryType.value === 'income' ? '#2563eb' : '#ef4444')
}
})
categoryName.value = ''
closeAddSheet()
await refreshCategories()
} catch {
addSheetError.value = '카테고리를 추가하지 못했습니다.'
}
}
async function saveCategory() {
if (!selectedCategory.value) return
categoryMessage.value = ''
await $fetch(`/api/categories/${selectedCategory.value.id}`, {
method: 'PATCH',
body: categoryEditForm
})
await refreshCategories()
selectedCategoryId.value = ''
}
async function deleteCategory() {
if (!selectedCategory.value) return
categoryMessage.value = ''
try {
await $fetch(`/api/categories/${selectedCategory.value.id}`, { method: 'DELETE' })
const deletedParentId = selectedCategory.value.parentId
await refreshCategories()
selectedCategoryId.value = ''
if (!deletedParentId) selectedParentId.value = ''
} catch (err: any) {
const status = err?.statusCode || err?.response?.status
const statusMessage = err?.statusMessage || err?.response?._data?.statusMessage || ''
if (status === 409 && statusMessage === 'Category has children') {
categoryMessage.value = '하위 카테고리가 있는 상위 카테고리는 삭제할 수 없습니다.'
return
}
if (status === 409) {
categoryMessage.value = '거래가 연결된 카테고리는 삭제할 수 없습니다.'
return
}
categoryMessage.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
}
}
async function savePreferences() {
preferencePending.value = true
preferenceMessage.value = ''
try {
await $fetch('/api/user-settings', {
method: 'PATCH',
body: preferenceForm
})
await refreshPreferences()
preferenceMessageType.value = 'ok'
preferenceMessage.value = '기본 설정을 저장했습니다.'
} catch {
preferenceMessageType.value = 'error'
preferenceMessage.value = '기본 설정을 저장하지 못했습니다.'
} finally {
preferencePending.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>