Add user preference settings
This commit is contained in:
1
assets/icons/add.svg
Normal file
1
assets/icons/add.svg
Normal 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 |
@@ -77,3 +77,5 @@
|
||||
- 자산 상세 화면의 월 이동 위치를 헤더 아래 공통 구조로 맞추고, 일별/월별/연별 보기 전환과 전환 모션을 추가했다.
|
||||
- 로컬 개발 DB에 누락되어 있던 공유 링크와 카테고리 표시 migration을 적용하고, 빈 이체 migration 디렉터리를 제거했다.
|
||||
- 설정 홈을 계정, 가계부, 데이터 섹션으로 나누어 각 항목의 목적이 먼저 보이도록 정리했다.
|
||||
- 플로팅 추가 버튼과 설정 화면 추가 버튼을 텍스트 `+` 대신 `assets/icons/add.svg` 아이콘으로 교체했다.
|
||||
- 사용자 설정 테이블과 API를 추가하고, 설정 화면에서 주 화폐, 월 시작일, 주간 시작일을 저장할 수 있도록 했다.
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
- 자산 관리는 활성/비활성 토글을 쓰지 않고, 등록된 자산은 모두 사용하는 것으로 본다.
|
||||
- 통계 화면은 그래프보다 숫자와 분류별 리스트를 우선한다.
|
||||
- 방향 이동 UI는 `assets/icons`의 키보드 화살표 SVG를 사용한다.
|
||||
- 추가 버튼 UI는 텍스트 `+` 대신 `assets/icons/add.svg`를 사용한다.
|
||||
- 거래 목록의 카테고리 표시는 하위 카테고리가 있으면 하위 이름만, 없으면 상위 이름만 보여준다.
|
||||
- 카테고리 아이콘과 색상은 카테고리 관리 상세 화면에서 수정하고 거래 목록 아이콘에 즉시 반영한다.
|
||||
|
||||
@@ -37,6 +38,7 @@
|
||||
- 자산은 활성/비활성으로 관리하지 않는다. 삭제는 거래가 연결되지 않은 자산에 한해 제공한다.
|
||||
- 거래에 연결되는 카테고리 선택은 하위가 있으면 하위 카테고리를 우선하고, 하위가 없는 경우에만 상위 카테고리를 직접 선택한다.
|
||||
- CSV Export는 데이터 보존을 위한 필수 기능으로 유지한다.
|
||||
- 주 화폐, 월 시작일, 주간 시작일 같은 사용자별 기본 설정은 `UserSetting`에 저장한다.
|
||||
|
||||
## 개발/운영 기준
|
||||
|
||||
|
||||
@@ -59,15 +59,16 @@
|
||||
<NuxtLink
|
||||
v-if="showFab"
|
||||
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="거래 추가"
|
||||
>
|
||||
+
|
||||
<span class="nav-icon h-7 w-7" v-html="addIcon" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import addIconRaw from '~/assets/icons/add.svg?raw'
|
||||
import assetIconRaw from '~/assets/icons/account_balance_wallet.svg?raw'
|
||||
import reportIconRaw from '~/assets/icons/bar_chart.svg?raw'
|
||||
import receiptIconRaw from '~/assets/icons/receipt_long.svg?raw'
|
||||
@@ -85,6 +86,7 @@ const items = [
|
||||
{ to: '/assets', label: '자산', icon: navIcon(assetIconRaw) },
|
||||
{ to: '/settings', label: '설정', icon: navIcon(settingsIconRaw) }
|
||||
]
|
||||
const addIcon = navIcon(addIconRaw)
|
||||
|
||||
const showNav = computed(() => route.path !== '/login')
|
||||
const showFab = computed(() => showNav.value && route.path !== '/add' && !route.path.startsWith('/transactions/'))
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
<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"
|
||||
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>
|
||||
|
||||
@@ -161,6 +161,56 @@
|
||||
</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">
|
||||
@@ -410,11 +460,16 @@
|
||||
|
||||
<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' | 'assets' | 'categories'
|
||||
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')
|
||||
@@ -427,6 +482,7 @@ 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 = [
|
||||
{
|
||||
@@ -443,6 +499,7 @@ const menuSections = [
|
||||
title: '가계부',
|
||||
description: '입력할 때 사용하는 기본 항목입니다.',
|
||||
items: [
|
||||
{ id: 'preferences', label: '기본 설정', description: '화폐, 월 시작일, 주간 시작일' },
|
||||
{ id: 'assets', label: '자산 관리', description: '자산 추가, 수정, 삭제' },
|
||||
{ id: 'categories', label: '카테고리 관리', description: '수입/지출 상위 분류와 하위 분류' }
|
||||
]
|
||||
@@ -467,10 +524,13 @@ const shareForm = reactive({
|
||||
})
|
||||
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 || ''
|
||||
})
|
||||
@@ -479,6 +539,18 @@ const passwordForm = reactive({
|
||||
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',
|
||||
@@ -491,6 +563,7 @@ 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 '카테고리 정보'
|
||||
@@ -547,6 +620,15 @@ watch(selectedAsset, (asset: any) => {
|
||||
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 = ''
|
||||
@@ -559,6 +641,9 @@ function open(nextView: SettingsView) {
|
||||
accountMessage.value = ''
|
||||
passwordMessage.value = ''
|
||||
}
|
||||
if (nextView === 'preferences') {
|
||||
preferenceMessage.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!import.meta.client) return `/share/${token}`
|
||||
return `${window.location.origin}/share/${token}`
|
||||
|
||||
@@ -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;
|
||||
@@ -38,6 +38,19 @@ model User {
|
||||
transactions Transaction[]
|
||||
recurringTransactions RecurringTransaction[]
|
||||
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 {
|
||||
|
||||
11
server/api/user-settings/index.get.ts
Normal file
11
server/api/user-settings/index.get.ts
Normal 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 }
|
||||
})
|
||||
})
|
||||
34
server/api/user-settings/index.patch.ts
Normal file
34
server/api/user-settings/index.patch.ts
Normal 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 }
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user