From 5daf2d39252286de6047b7a97edcb088ea3c4e23 Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 13 Aug 2026 18:33:59 +0900 Subject: [PATCH] Fix signup defaults and asset management --- docs/changelog.md | 4 + docs/project-guidelines.md | 5 +- pages/login.vue | 7 +- pages/settings.vue | 190 ++++++++++++++++++++++++------- server/api/assets/[id].delete.ts | 19 ++++ server/api/assets/[id].patch.ts | 3 + server/api/assets/index.post.ts | 7 +- server/api/auth/login.post.ts | 2 + server/api/auth/register.post.ts | 17 +-- server/utils/defaults.ts | 16 ++- todo.md | 3 - 11 files changed, 217 insertions(+), 56 deletions(-) create mode 100644 server/api/assets/[id].delete.ts diff --git a/docs/changelog.md b/docs/changelog.md index 759caee..98249f6 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -53,3 +53,7 @@ - 거래 입력/수정 화면의 상단 저장 버튼을 제거하고 하단 저장/취소 2:1 버튼 구조로 변경했다. - 거래 입력/수정 화면에 필수값 누락 토스트와 해당 입력 줄 포커스/강조 피드백을 추가했다. - 거래 입력/수정 행 스타일을 박스형 입력에서 밑줄형 입력으로 변경했다. +- 회원가입 중 기본 데이터 생성 실패 시 계정만 생성되는 문제를 막기 위해 가입 처리를 트랜잭션으로 묶었다. +- 로그인 시 기본 자산/카테고리가 비어 있는 기존 계정을 자동 보정하도록 했다. +- 설정의 자산 관리를 `+` 추가, 목록, 상세 수정/삭제 흐름으로 변경하고 활성/비활성 토글을 제거했다. +- 거래가 연결되지 않은 자산을 삭제할 수 있는 API를 추가했다. diff --git a/docs/project-guidelines.md b/docs/project-guidelines.md index a3ebb5c..117cb5c 100644 --- a/docs/project-guidelines.md +++ b/docs/project-guidelines.md @@ -22,7 +22,8 @@ - 필수값 없이 저장을 누르면 토스트로 부족한 항목을 알려주고 해당 입력 줄에 포커스와 강조선을 준다. - 거래 입력 행은 위아래 박스 라인 대신 각 입력란 하단 밑줄만 사용하고, 포커스 중인 줄만 포인트 색상으로 강조한다. - 셀렉트 박스보다 모바일 친화적인 바텀 시트를 우선한다. -- 활성/비활성 같은 상태는 텍스트 버튼보다 토글처럼 현재 상태가 보이는 UI를 쓴다. +- 상태를 켜고 끄는 기능이 꼭 필요한 경우에는 텍스트 버튼보다 토글처럼 현재 상태가 보이는 UI를 쓴다. +- 자산 관리는 활성/비활성 토글을 쓰지 않고, 등록된 자산은 모두 사용하는 것으로 본다. - 통계 화면은 그래프보다 숫자와 분류별 리스트를 우선한다. - 방향 이동 UI는 `assets/icons`의 키보드 화살표 SVG를 사용한다. - 거래 목록의 카테고리 표시는 하위 카테고리가 있으면 하위 이름만, 없으면 상위 이름만 보여준다. @@ -33,7 +34,7 @@ - PostgreSQL을 기본 DB로 사용한다. - Prisma migration은 저장소에 포함한다. - 자산 잔액은 현재값을 저장하지 않고 거래 합계로 계산한다. -- 카테고리와 자산은 완전 삭제보다 비활성화를 우선한다. +- 자산은 활성/비활성으로 관리하지 않는다. 삭제는 거래가 연결되지 않은 자산에 한해 제공한다. - 거래에 연결되는 카테고리 선택은 하위가 있으면 하위 카테고리를 우선하고, 하위가 없는 경우에만 상위 카테고리를 직접 선택한다. - CSV Export는 데이터 보존을 위한 필수 기능으로 유지한다. diff --git a/pages/login.vue b/pages/login.vue index 088daca..ae458d5 100644 --- a/pages/login.vue +++ b/pages/login.vue @@ -77,10 +77,15 @@ async function submit() { await navigateTo('/') } catch (err: any) { const status = err?.statusCode || err?.response?.status + const statusMessage = err?.statusMessage || err?.response?._data?.statusMessage || '' if (mode.value === 'register' && status === 409) { error.value = '이미 가입된 이메일입니다.' + } else if (mode.value === 'register' && statusMessage) { + error.value = statusMessage === 'Invalid registration' + ? '이메일과 8자 이상 비밀번호를 확인해 주세요.' + : '가입 처리 중 문제가 생겼습니다. 잠시 후 다시 시도해 주세요.' } else if (mode.value === 'register') { - error.value = '이메일과 8자 이상 비밀번호를 확인해 주세요.' + error.value = '가입 처리 중 문제가 생겼습니다. 잠시 후 다시 시도해 주세요.' } else { error.value = '이메일 또는 비밀번호를 확인해 주세요.' } diff --git a/pages/settings.vue b/pages/settings.vue index 1221acd..9f625a1 100644 --- a/pages/settings.vue +++ b/pages/settings.vue @@ -148,42 +148,67 @@ -
-
- -
- 잔액 계산 방식 -
- - +
+ +
+
+ +
+ +
+ 계산 방식 +
+ + +
+
+ +
+ + +
+
+ +

{{ assetMessage }}

+
+ +
- - -
-
-
-

{{ asset.name }}

-

- {{ asset.balanceType === 'cumulative' ? '계속 누적' : '매월 새로 시작' }} · {{ asset.active ? '활성' : '비활성' }} -

-
- -
+
+ + +

+ 등록된 자산이 없습니다. +

@@ -318,13 +343,41 @@
-
+

{{ addSheetTitle }}

+ + + ('se const categoryType = ref('expense') const selectedParentId = ref('') const selectedCategoryId = ref('') +const selectedAssetId = ref('') const addSheetOpen = ref(false) const categoryName = ref('') -const { data: assetData, refresh: refreshAssets } = await useFetch('/api/assets', { query: { includeInactive: true } }) +const { data: assetData, refresh: refreshAssets } = await useFetch('/api/assets') const { data: categoryData, refresh: refreshCategories } = await useFetch('/api/categories', { query: { includeInactive: true } }) const { data: shareLinkData, refresh: refreshShareLinks } = await useFetch('/api/share-links') const menuItems = [ { id: 'account', label: '계정 관리', description: '로그인 계정 확인과 로그아웃' }, { id: 'sharing', label: '공유 링크 관리', description: '읽기 전용 공유 링크 생성과 삭제' }, - { id: 'assets', label: '자산 관리', description: '자산 추가, 잔액 계산 방식, 활성 상태' }, + { id: 'assets', label: '자산 관리', description: '자산 추가, 수정, 삭제' }, { id: 'categories', label: '카테고리 관리', description: '수입/지출 상위 분류와 하위 분류' } ] as const @@ -368,6 +422,12 @@ const assetForm = reactive({ balanceType: 'cumulative', initialBalance: 0 }) +const assetEditForm = reactive({ + name: '', + balanceType: 'cumulative', + initialBalance: 0 +}) +const assetMessage = ref('') const shareDayOptions = [1, 7, 30] const shareForm = reactive({ name: '', @@ -398,6 +458,7 @@ 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 @@ -407,6 +468,7 @@ const title = computed(() => { 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' ? '수입' : '지출') @@ -421,8 +483,11 @@ const visibleCategories = computed(() => { .filter((category: any) => category.type === categoryType.value && !category.parentId) .sort((a: any, b: any) => a.sortOrder - b.sortOrder) }) -const showAddButton = computed(() => view.value === 'categories') -const addSheetTitle = computed(() => selectedParentCategory.value ? `${selectedParentCategory.value.name} 하위 카테고리 추가` : `${categoryTypeLabel.value} 상위 카테고리 추가`) +const showAddButton = computed(() => (view.value === 'assets' && !selectedAsset.value) || view.value === 'categories') +const addSheetTitle = computed(() => { + if (view.value === 'assets') return '자산 추가' + return selectedParentCategory.value ? `${selectedParentCategory.value.name} 하위 카테고리 추가` : `${categoryTypeLabel.value} 상위 카테고리 추가` +}) watch(categoryType, () => { selectedParentId.value = '' @@ -438,10 +503,22 @@ watch(selectedCategory, (category: any) => { }) }) +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 = '' if (nextView === 'account') { accountForm.name = session.value?.name || '' accountMessage.value = '' @@ -450,6 +527,10 @@ function open(nextView: SettingsView) { } function goBack() { + if (selectedAssetId.value) { + selectedAssetId.value = '' + return + } if (selectedCategoryId.value) { selectedCategoryId.value = '' return @@ -463,9 +544,19 @@ function goBack() { function openAddSheet() { categoryName.value = '' + Object.assign(assetForm, { name: '', balanceType: 'cumulative', initialBalance: 0 }) addSheetOpen.value = true } +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) @@ -499,12 +590,33 @@ function categoryStyle(category: any) { async function createAsset() { await $fetch('/api/assets', { method: 'POST', body: assetForm }) Object.assign(assetForm, { name: '', balanceType: 'cumulative', initialBalance: 0 }) + addSheetOpen.value = false await refreshAssets() } -async function toggleAsset(asset: any, active: boolean) { - await $fetch(`/api/assets/${asset.id}`, { method: 'PATCH', body: { active } }) +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() { diff --git a/server/api/assets/[id].delete.ts b/server/api/assets/[id].delete.ts new file mode 100644 index 0000000..9a9a232 --- /dev/null +++ b/server/api/assets/[id].delete.ts @@ -0,0 +1,19 @@ +import { getRouterParam, createError } from 'h3' +import { prisma } from '../../utils/db' +import { requireUser } from '../../utils/auth' + +export default defineEventHandler(async (event) => { + const user = await requireUser(event) + const id = getRouterParam(event, 'id')! + + const asset = await prisma.asset.findFirst({ where: { id, userId: user.id } }) + if (!asset) throw createError({ statusCode: 404, statusMessage: 'Asset not found' }) + + const transactionCount = await prisma.transaction.count({ where: { assetId: id, userId: user.id } }) + if (transactionCount > 0) { + throw createError({ statusCode: 409, statusMessage: 'Asset has transactions' }) + } + + await prisma.asset.delete({ where: { id } }) + return { ok: true } +}) diff --git a/server/api/assets/[id].patch.ts b/server/api/assets/[id].patch.ts index 98cb2bb..c0bf370 100644 --- a/server/api/assets/[id].patch.ts +++ b/server/api/assets/[id].patch.ts @@ -9,6 +9,9 @@ export default defineEventHandler(async (event) => { const asset = await prisma.asset.findFirst({ where: { id, userId: user.id } }) if (!asset) throw createError({ statusCode: 404, statusMessage: 'Asset not found' }) + if (body.name !== undefined && !body.name.trim()) { + throw createError({ statusCode: 400, statusMessage: 'Asset name is required' }) + } return prisma.asset.update({ where: { id }, diff --git a/server/api/assets/index.post.ts b/server/api/assets/index.post.ts index f1355aa..fa7012f 100644 --- a/server/api/assets/index.post.ts +++ b/server/api/assets/index.post.ts @@ -1,16 +1,19 @@ -import { readBody } from 'h3' +import { readBody, createError } from 'h3' import { prisma } from '../../utils/db' import { requireUser } from '../../utils/auth' export default defineEventHandler(async (event) => { const user = await requireUser(event) const body = await readBody<{ name: string; balanceType: 'cumulative' | 'monthly_reset'; initialBalance?: number }>(event) + const name = body.name?.trim() + if (!name) throw createError({ statusCode: 400, statusMessage: 'Asset name is required' }) + const count = await prisma.asset.count({ where: { userId: user.id } }) return prisma.asset.create({ data: { userId: user.id, - name: body.name.trim(), + name, balanceType: body.balanceType, initialBalance: body.balanceType === 'cumulative' ? Number(body.initialBalance || 0) : 0, sortOrder: count diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts index bc6c1f1..34150e9 100644 --- a/server/api/auth/login.post.ts +++ b/server/api/auth/login.post.ts @@ -2,6 +2,7 @@ import { compare } from 'bcryptjs' import { readBody, createError } from 'h3' import { prisma } from '../../utils/db' import { setSession } from '../../utils/auth' +import { ensureDefaultMoneyData } from '../../utils/defaults' export default defineEventHandler(async (event) => { const body = await readBody<{ email: string; password: string }>(event) @@ -11,6 +12,7 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' }) } + await ensureDefaultMoneyData(prisma, user.id) setSession(event, user.id) return { id: user.id, email: user.email, name: user.name } }) diff --git a/server/api/auth/register.post.ts b/server/api/auth/register.post.ts index 25fcf73..04ed71e 100644 --- a/server/api/auth/register.post.ts +++ b/server/api/auth/register.post.ts @@ -19,15 +19,18 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 409, statusMessage: 'Email already exists' }) } - const user = await prisma.user.create({ - data: { - email, - name, - passwordHash: await hash(password, 10) - } + const user = await prisma.$transaction(async (tx) => { + const created = await tx.user.create({ + data: { + email, + name, + passwordHash: await hash(password, 10) + } + }) + await createDefaultMoneyData(tx, created.id) + return created }) - await createDefaultMoneyData(prisma, user.id) setSession(event, user.id) return { id: user.id, email: user.email, name: user.name } diff --git a/server/utils/defaults.ts b/server/utils/defaults.ts index e5d50bd..eba64a8 100644 --- a/server/utils/defaults.ts +++ b/server/utils/defaults.ts @@ -1,6 +1,8 @@ -import type { PrismaClient } from '@prisma/client' +import type { Prisma, PrismaClient } from '@prisma/client' -export async function createDefaultMoneyData(prisma: PrismaClient, userId: string) { +type MoneyDataClient = PrismaClient | Prisma.TransactionClient + +export async function createDefaultMoneyData(prisma: MoneyDataClient, userId: string) { const cash = await prisma.asset.create({ data: { userId, @@ -34,3 +36,13 @@ export async function createDefaultMoneyData(prisma: PrismaClient, userId: strin return { cash } } + +export async function ensureDefaultMoneyData(prisma: MoneyDataClient, userId: string) { + const [assetCount, categoryCount] = await Promise.all([ + prisma.asset.count({ where: { userId } }), + prisma.category.count({ where: { userId } }) + ]) + + if (assetCount || categoryCount) return + await createDefaultMoneyData(prisma, userId) +} diff --git a/todo.md b/todo.md index 094f4ee..5e3a987 100644 --- a/todo.md +++ b/todo.md @@ -36,8 +36,6 @@ - 자산 목록 화면의 PC/모바일 레이아웃을 다듬는다. - 자산 상세 화면의 월 이동 헤더를 공통 구조에 맞춘다. - 자산 상세에서 일별/월별/연별 보기 전환을 구현한다. -- 자산 추가/수정 화면을 별도 상세 화면 또는 바텀 시트로 정리한다. -- 자산 삭제 또는 숨김 처리 규칙을 정한다. ## 6. 통계 @@ -50,7 +48,6 @@ ## 7. 설정 - 설정 홈의 항목 구성을 다시 정리한다. -- 자산 관리 화면을 추가/수정/삭제가 명확한 상세 구조로 바꾼다. - 주 화폐, 월 시작일, 주간 시작일 같은 기본 설정 항목을 구현한다. - 설정 변경이 실제 화면 계산과 표시 방식에 반영되도록 연결한다.