810 lines
32 KiB
Vue
810 lines
32 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'" 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>
|
|
|
|
<p v-if="!shareLinks.length" class="px-4 py-12 text-center text-muted">
|
|
만든 공유 링크가 없습니다.
|
|
</p>
|
|
</div>
|
|
</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>
|
|
|
|
<p v-if="!assets.length" class="px-4 py-12 text-center text-muted">
|
|
등록된 자산이 없습니다.
|
|
</p>
|
|
</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>
|
|
|
|
<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="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 { 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 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 menuSections = [
|
|
{
|
|
id: 'account',
|
|
title: '계정',
|
|
description: '로그인 정보와 공유 권한을 관리합니다.',
|
|
items: [
|
|
{ id: 'account', label: '계정 관리', description: '이름, 비밀번호, 로그아웃' },
|
|
{ id: 'sharing', label: '공유 링크 관리', description: '읽기 전용 링크 생성과 삭제' }
|
|
]
|
|
},
|
|
{
|
|
id: 'money',
|
|
title: '가계부',
|
|
description: '입력할 때 사용하는 기본 항목입니다.',
|
|
items: [
|
|
{ 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 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 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 (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 = ''
|
|
})
|
|
|
|
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 = ''
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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>
|