Add category presentation management
This commit is contained in:
@@ -113,17 +113,21 @@ const assetOptions = computed(() => assets.value.map((asset: any) => ({
|
||||
label: asset.name,
|
||||
description: asset.balanceType === 'cumulative' ? '계속 누적' : '매월 새로 시작'
|
||||
})))
|
||||
const categoryOptions = computed(() => filteredCategories.value.map((category: any) => ({
|
||||
const selectableCategories = computed(() => {
|
||||
const activeIdsWithChildren = new Set(filteredCategories.value.filter((category: any) => category.parentId).map((category: any) => category.parentId))
|
||||
return filteredCategories.value.filter((category: any) => !activeIdsWithChildren.has(category.id))
|
||||
})
|
||||
const categoryOptions = computed(() => selectableCategories.value.map((category: any) => ({
|
||||
value: category.id,
|
||||
label: category.name,
|
||||
description: category.type === 'income' ? '수입' : '지출'
|
||||
description: category.parent?.name || (category.type === 'income' ? '수입' : '지출')
|
||||
})))
|
||||
|
||||
watch(assets, (items: any[]) => {
|
||||
if (!form.assetId && items[0]) form.assetId = items[0].id
|
||||
}, { immediate: true })
|
||||
|
||||
watch(filteredCategories, (items: any[]) => {
|
||||
watch(selectableCategories, (items: any[]) => {
|
||||
if (!items.some(item => item.id === form.categoryId)) form.categoryId = items[0]?.id || ''
|
||||
}, { immediate: true })
|
||||
|
||||
|
||||
@@ -155,8 +155,61 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="py-4">
|
||||
<div v-if="!selectedParentCategory" class="px-4">
|
||||
<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"
|
||||
@@ -177,7 +230,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 divide-y divide-line border-y border-line bg-white">
|
||||
<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"
|
||||
@@ -185,11 +259,19 @@
|
||||
class="flex w-full items-center justify-between px-4 py-4 text-left active:bg-paper"
|
||||
@click="selectCategory(category)"
|
||||
>
|
||||
<span>
|
||||
<span class="block text-base font-semibold text-ink">{{ category.name }}</span>
|
||||
<span class="mt-1 block text-xs text-muted">{{ categoryDescription(category) }}</span>
|
||||
<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 v-if="!selectedParentCategory" class="h-6 w-6 opacity-60" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
|
||||
<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">
|
||||
@@ -226,6 +308,7 @@
|
||||
|
||||
<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'
|
||||
@@ -234,6 +317,7 @@ 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 } })
|
||||
@@ -257,11 +341,19 @@ const shareForm = reactive({
|
||||
name: '',
|
||||
days: 7
|
||||
})
|
||||
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 '설정'
|
||||
@@ -270,6 +362,7 @@ 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) {
|
||||
@@ -287,14 +380,29 @@ const addSheetTitle = computed(() => selectedParentCategory.value ? `${selectedP
|
||||
|
||||
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 = ''
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (selectedCategoryId.value) {
|
||||
selectedCategoryId.value = ''
|
||||
return
|
||||
}
|
||||
if (selectedParentId.value) {
|
||||
selectedParentId.value = ''
|
||||
return
|
||||
@@ -308,16 +416,35 @@ function openAddSheet() {
|
||||
}
|
||||
|
||||
function selectCategory(category: any) {
|
||||
if (selectedParentId.value) return
|
||||
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 categoryTypeLabel.value
|
||||
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 })
|
||||
@@ -335,7 +462,9 @@ async function createCategory() {
|
||||
body: {
|
||||
type: categoryType.value,
|
||||
parentId: selectedParentId.value || '',
|
||||
name: categoryName.value
|
||||
name: categoryName.value,
|
||||
icon: selectedParentCategory.value?.icon || 'circle',
|
||||
color: selectedParentCategory.value?.color || '#6b7673'
|
||||
}
|
||||
})
|
||||
categoryName.value = ''
|
||||
@@ -343,6 +472,16 @@ async function createCategory() {
|
||||
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',
|
||||
|
||||
@@ -114,10 +114,14 @@ const assetOptions = computed(() => assets.value.map((asset: any) => ({
|
||||
label: asset.name,
|
||||
description: asset.balanceType === 'cumulative' ? '계속 누적' : '매월 새로 시작'
|
||||
})))
|
||||
const categoryOptions = computed(() => filteredCategories.value.map((category: any) => ({
|
||||
const selectableCategories = computed(() => {
|
||||
const activeIdsWithChildren = new Set(filteredCategories.value.filter((category: any) => category.parentId).map((category: any) => category.parentId))
|
||||
return filteredCategories.value.filter((category: any) => !activeIdsWithChildren.has(category.id))
|
||||
})
|
||||
const categoryOptions = computed(() => selectableCategories.value.map((category: any) => ({
|
||||
value: category.id,
|
||||
label: category.name,
|
||||
description: category.type === 'income' ? '수입' : '지출'
|
||||
description: category.parent?.name || (category.type === 'income' ? '수입' : '지출')
|
||||
})))
|
||||
const locationLabel = computed(() => form.latitude && form.longitude)
|
||||
const mapsUrl = computed(() => `https://www.google.com/maps?q=${form.latitude},${form.longitude}`)
|
||||
@@ -137,7 +141,7 @@ watch(transaction, (value) => {
|
||||
})
|
||||
}, { immediate: true })
|
||||
|
||||
watch(filteredCategories, (items: any[]) => {
|
||||
watch(selectableCategories, (items: any[]) => {
|
||||
if (!items.some(item => item.id === form.categoryId)) form.categoryId = items[0]?.id || ''
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user