Add user preference settings

This commit is contained in:
2026-08-14 12:44:14 +09:00
parent c8103cc4d6
commit 0f6bf385eb
10 changed files with 192 additions and 7 deletions

1
assets/icons/add.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M440-440H200v-80h240v-240h80v240h240v80H520v240h-80v-240Z"/></svg>

After

Width:  |  Height:  |  Size: 182 B

View File

@@ -77,3 +77,5 @@
- 자산 상세 화면의 월 이동 위치를 헤더 아래 공통 구조로 맞추고, 일별/월별/연별 보기 전환과 전환 모션을 추가했다. - 자산 상세 화면의 월 이동 위치를 헤더 아래 공통 구조로 맞추고, 일별/월별/연별 보기 전환과 전환 모션을 추가했다.
- 로컬 개발 DB에 누락되어 있던 공유 링크와 카테고리 표시 migration을 적용하고, 빈 이체 migration 디렉터리를 제거했다. - 로컬 개발 DB에 누락되어 있던 공유 링크와 카테고리 표시 migration을 적용하고, 빈 이체 migration 디렉터리를 제거했다.
- 설정 홈을 계정, 가계부, 데이터 섹션으로 나누어 각 항목의 목적이 먼저 보이도록 정리했다. - 설정 홈을 계정, 가계부, 데이터 섹션으로 나누어 각 항목의 목적이 먼저 보이도록 정리했다.
- 플로팅 추가 버튼과 설정 화면 추가 버튼을 텍스트 `+` 대신 `assets/icons/add.svg` 아이콘으로 교체했다.
- 사용자 설정 테이블과 API를 추가하고, 설정 화면에서 주 화폐, 월 시작일, 주간 시작일을 저장할 수 있도록 했다.

View File

@@ -26,6 +26,7 @@
- 자산 관리는 활성/비활성 토글을 쓰지 않고, 등록된 자산은 모두 사용하는 것으로 본다. - 자산 관리는 활성/비활성 토글을 쓰지 않고, 등록된 자산은 모두 사용하는 것으로 본다.
- 통계 화면은 그래프보다 숫자와 분류별 리스트를 우선한다. - 통계 화면은 그래프보다 숫자와 분류별 리스트를 우선한다.
- 방향 이동 UI는 `assets/icons`의 키보드 화살표 SVG를 사용한다. - 방향 이동 UI는 `assets/icons`의 키보드 화살표 SVG를 사용한다.
- 추가 버튼 UI는 텍스트 `+` 대신 `assets/icons/add.svg`를 사용한다.
- 거래 목록의 카테고리 표시는 하위 카테고리가 있으면 하위 이름만, 없으면 상위 이름만 보여준다. - 거래 목록의 카테고리 표시는 하위 카테고리가 있으면 하위 이름만, 없으면 상위 이름만 보여준다.
- 카테고리 아이콘과 색상은 카테고리 관리 상세 화면에서 수정하고 거래 목록 아이콘에 즉시 반영한다. - 카테고리 아이콘과 색상은 카테고리 관리 상세 화면에서 수정하고 거래 목록 아이콘에 즉시 반영한다.
@@ -37,6 +38,7 @@
- 자산은 활성/비활성으로 관리하지 않는다. 삭제는 거래가 연결되지 않은 자산에 한해 제공한다. - 자산은 활성/비활성으로 관리하지 않는다. 삭제는 거래가 연결되지 않은 자산에 한해 제공한다.
- 거래에 연결되는 카테고리 선택은 하위가 있으면 하위 카테고리를 우선하고, 하위가 없는 경우에만 상위 카테고리를 직접 선택한다. - 거래에 연결되는 카테고리 선택은 하위가 있으면 하위 카테고리를 우선하고, 하위가 없는 경우에만 상위 카테고리를 직접 선택한다.
- CSV Export는 데이터 보존을 위한 필수 기능으로 유지한다. - CSV Export는 데이터 보존을 위한 필수 기능으로 유지한다.
- 주 화폐, 월 시작일, 주간 시작일 같은 사용자별 기본 설정은 `UserSetting`에 저장한다.
## 개발/운영 기준 ## 개발/운영 기준

View File

@@ -59,15 +59,16 @@
<NuxtLink <NuxtLink
v-if="showFab" v-if="showFab"
to="/add" to="/add"
class="fixed bottom-20 right-4 z-30 flex h-14 w-14 items-center justify-center rounded-full bg-red-500 text-3xl leading-none text-white shadow-soft active:scale-95 md:bottom-10 md:right-10" class="fixed bottom-20 right-4 z-30 flex h-14 w-14 items-center justify-center rounded-full bg-red-500 text-white shadow-soft active:scale-95 md:bottom-10 md:right-10"
aria-label="거래 추가" aria-label="거래 추가"
> >
+ <span class="nav-icon h-7 w-7" v-html="addIcon" />
</NuxtLink> </NuxtLink>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import addIconRaw from '~/assets/icons/add.svg?raw'
import assetIconRaw from '~/assets/icons/account_balance_wallet.svg?raw' import assetIconRaw from '~/assets/icons/account_balance_wallet.svg?raw'
import reportIconRaw from '~/assets/icons/bar_chart.svg?raw' import reportIconRaw from '~/assets/icons/bar_chart.svg?raw'
import receiptIconRaw from '~/assets/icons/receipt_long.svg?raw' import receiptIconRaw from '~/assets/icons/receipt_long.svg?raw'
@@ -85,6 +86,7 @@ const items = [
{ to: '/assets', label: '자산', icon: navIcon(assetIconRaw) }, { to: '/assets', label: '자산', icon: navIcon(assetIconRaw) },
{ to: '/settings', label: '설정', icon: navIcon(settingsIconRaw) } { to: '/settings', label: '설정', icon: navIcon(settingsIconRaw) }
] ]
const addIcon = navIcon(addIconRaw)
const showNav = computed(() => route.path !== '/login') const showNav = computed(() => route.path !== '/login')
const showFab = computed(() => showNav.value && route.path !== '/add' && !route.path.startsWith('/transactions/')) const showFab = computed(() => showNav.value && route.path !== '/add' && !route.path.startsWith('/transactions/'))

View File

@@ -17,11 +17,11 @@
<button <button
v-if="showAddButton" v-if="showAddButton"
type="button" type="button"
class="flex h-9 w-9 items-center justify-center rounded-md text-2xl leading-none text-brand-700 active:bg-paper" class="flex h-9 w-9 items-center justify-center rounded-md text-brand-700 active:bg-paper"
aria-label="추가" aria-label="추가"
@click="openAddSheet" @click="openAddSheet"
> >
+ <span class="nav-icon h-6 w-6" v-html="addIcon" />
</button> </button>
</header> </header>
@@ -161,6 +161,56 @@
</div> </div>
</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"> <div v-else-if="view === 'assets'" class="py-4">
<form v-if="selectedAsset" class="px-4" @submit.prevent="saveAsset"> <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"> <section class="space-y-5 border-y border-line bg-white px-4 py-4">
@@ -410,11 +460,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { format } from 'date-fns' 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' import { CATEGORY_COLOR_OPTIONS, CATEGORY_ICON_OPTIONS, categoryColor, categoryGlyph } from '~/utils/categoryPresentation'
type SettingsView = 'home' | 'account' | 'sharing' | 'assets' | 'categories' type SettingsView = 'home' | 'account' | 'sharing' | 'preferences' | 'assets' | 'categories'
type CategoryType = 'income' | 'expense' 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 view = ref<SettingsView>('home')
const session = useState<{ id: string; email: string; name: string } | null>('session') const session = useState<{ id: string; email: string; name: string } | null>('session')
const categoryType = ref<CategoryType>('expense') const categoryType = ref<CategoryType>('expense')
@@ -427,6 +482,7 @@ const categoryName = ref('')
const { data: assetData, refresh: refreshAssets } = await useFetch('/api/assets') const { data: assetData, refresh: refreshAssets } = await useFetch('/api/assets')
const { data: categoryData, refresh: refreshCategories } = await useFetch('/api/categories') const { data: categoryData, refresh: refreshCategories } = await useFetch('/api/categories')
const { data: shareLinkData, refresh: refreshShareLinks } = await useFetch('/api/share-links') const { data: shareLinkData, refresh: refreshShareLinks } = await useFetch('/api/share-links')
const { data: preferenceData, refresh: refreshPreferences } = await useFetch('/api/user-settings')
const menuSections = [ const menuSections = [
{ {
@@ -443,6 +499,7 @@ const menuSections = [
title: '가계부', title: '가계부',
description: '입력할 때 사용하는 기본 항목입니다.', description: '입력할 때 사용하는 기본 항목입니다.',
items: [ items: [
{ id: 'preferences', label: '기본 설정', description: '화폐, 월 시작일, 주간 시작일' },
{ id: 'assets', label: '자산 관리', description: '자산 추가, 수정, 삭제' }, { id: 'assets', label: '자산 관리', description: '자산 추가, 수정, 삭제' },
{ id: 'categories', label: '카테고리 관리', description: '수입/지출 상위 분류와 하위 분류' } { id: 'categories', label: '카테고리 관리', description: '수입/지출 상위 분류와 하위 분류' }
] ]
@@ -467,10 +524,13 @@ const shareForm = reactive({
}) })
const accountPending = ref(false) const accountPending = ref(false)
const passwordPending = ref(false) const passwordPending = ref(false)
const preferencePending = ref(false)
const accountMessage = ref('') const accountMessage = ref('')
const passwordMessage = ref('') const passwordMessage = ref('')
const preferenceMessage = ref('')
const accountMessageType = ref<'ok' | 'error'>('ok') const accountMessageType = ref<'ok' | 'error'>('ok')
const passwordMessageType = ref<'ok' | 'error'>('ok') const passwordMessageType = ref<'ok' | 'error'>('ok')
const preferenceMessageType = ref<'ok' | 'error'>('ok')
const accountForm = reactive({ const accountForm = reactive({
name: session.value?.name || '' name: session.value?.name || ''
}) })
@@ -479,6 +539,18 @@ const passwordForm = reactive({
nextPassword: '', nextPassword: '',
confirmPassword: '' 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({ const categoryEditForm = reactive({
name: '', name: '',
icon: 'circle', icon: 'circle',
@@ -491,6 +563,7 @@ const categoryColorOptions = CATEGORY_COLOR_OPTIONS
const title = computed(() => { const title = computed(() => {
if (view.value === 'account') return '계정 관리' if (view.value === 'account') return '계정 관리'
if (view.value === 'sharing') return '공유 링크 관리' if (view.value === 'sharing') return '공유 링크 관리'
if (view.value === 'preferences') return '기본 설정'
if (selectedAsset.value) return '자산 정보' if (selectedAsset.value) return '자산 정보'
if (view.value === 'assets') return '자산 관리' if (view.value === 'assets') return '자산 관리'
if (selectedCategory.value) return '카테고리 정보' if (selectedCategory.value) return '카테고리 정보'
@@ -547,6 +620,15 @@ watch(selectedAsset, (asset: any) => {
assetMessage.value = '' 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) { function open(nextView: SettingsView) {
view.value = nextView view.value = nextView
selectedParentId.value = '' selectedParentId.value = ''
@@ -559,6 +641,9 @@ function open(nextView: SettingsView) {
accountMessage.value = '' accountMessage.value = ''
passwordMessage.value = '' passwordMessage.value = ''
} }
if (nextView === 'preferences') {
preferenceMessage.value = ''
}
} }
function goBack() { function goBack() {
@@ -792,6 +877,25 @@ async function changePassword() {
} }
} }
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) { function shareUrl(token: string) {
if (!import.meta.client) return `/share/${token}` if (!import.meta.client) return `/share/${token}`
return `${window.location.origin}/share/${token}` return `${window.location.origin}/share/${token}`

View File

@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "UserSetting" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"primaryCurrency" TEXT NOT NULL DEFAULT 'KRW',
"monthStartDay" INTEGER NOT NULL DEFAULT 1,
"weekStartDay" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserSetting_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "UserSetting_userId_key" ON "UserSetting"("userId");
-- AddForeignKey
ALTER TABLE "UserSetting" ADD CONSTRAINT "UserSetting_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -38,6 +38,19 @@ model User {
transactions Transaction[] transactions Transaction[]
recurringTransactions RecurringTransaction[] recurringTransactions RecurringTransaction[]
shareLinks ShareLink[] shareLinks ShareLink[]
setting UserSetting?
}
model UserSetting {
id String @id @default(cuid())
userId String @unique
primaryCurrency String @default("KRW")
monthStartDay Int @default(1)
weekStartDay Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
model Asset { model Asset {

View File

@@ -0,0 +1,11 @@
import { prisma } from '../../utils/db'
import { requireUser } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
return prisma.userSetting.upsert({
where: { userId: user.id },
update: {},
create: { userId: user.id }
})
})

View File

@@ -0,0 +1,34 @@
import { createError, readBody } from 'h3'
import { prisma } from '../../utils/db'
import { requireUser } from '../../utils/auth'
const currencies = new Set(['KRW', 'USD', 'JPY'])
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const body = await readBody<{
primaryCurrency?: string
monthStartDay?: number
weekStartDay?: number
}>(event)
const primaryCurrency = body.primaryCurrency || 'KRW'
const monthStartDay = Number(body.monthStartDay || 1)
const weekStartDay = Number(body.weekStartDay ?? 0)
if (!currencies.has(primaryCurrency)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid currency' })
}
if (!Number.isInteger(monthStartDay) || monthStartDay < 1 || monthStartDay > 28) {
throw createError({ statusCode: 400, statusMessage: 'Invalid month start day' })
}
if (!Number.isInteger(weekStartDay) || weekStartDay < 0 || weekStartDay > 6) {
throw createError({ statusCode: 400, statusMessage: 'Invalid week start day' })
}
return prisma.userSetting.upsert({
where: { userId: user.id },
update: { primaryCurrency, monthStartDay, weekStartDay },
create: { userId: user.id, primaryCurrency, monthStartDay, weekStartDay }
})
})

View File

@@ -30,12 +30,10 @@
## 6. 설정 ## 6. 설정
- 주 화폐, 월 시작일, 주간 시작일 같은 기본 설정 항목을 구현한다.
- 설정 변경이 실제 화면 계산과 표시 방식에 반영되도록 연결한다. - 설정 변경이 실제 화면 계산과 표시 방식에 반영되도록 연결한다.
## 7. 데이터와 API ## 7. 데이터와 API
- 사용자 설정 테이블을 추가한다.
- 거래 목록 조회 성능을 고려해 필요한 index를 점검한다. - 거래 목록 조회 성능을 고려해 필요한 index를 점검한다.
- CSV 내보내기 컬럼을 실제 사용 흐름에 맞게 정리한다. - CSV 내보내기 컬럼을 실제 사용 흐름에 맞게 정리한다.