first commit

This commit is contained in:
2026-08-13 15:58:42 +09:00
commit 085bf0a6ee
52 changed files with 19420 additions and 0 deletions

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
DATABASE_URL="postgresql://money:money@localhost:5432/money?schema=public"
SESSION_SECRET="replace-with-a-long-random-secret"
NUXT_PUBLIC_APP_NAME="Money"

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
.nuxt
.output
node_modules
.npm-cache
.env
.env.*
!.env.example
uploads
*.log

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM node:24-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:24-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run db:generate
RUN npm run build
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.output ./.output
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
# Money
모바일 입력을 우선한 개인 가계부 PWA입니다. Nuxt 3, Vue 3, TypeScript, Tailwind CSS, PostgreSQL, Prisma, Docker Compose를 기본으로 구성했습니다.
## 시작하기
```bash
npm install
cp .env.example .env
docker compose up -d db
npm run db:migrate -- --name init
npm run db:seed
npm run dev
```
기본 계정:
```text
email: user@example.com
password: money1234
```
## Docker
```bash
docker compose up --build
```
운영 전 `SESSION_SECRET`과 DB 비밀번호를 반드시 변경하세요.
## 문서
- [요구사항](docs/requirements.md)
- [아키텍처](docs/architecture.md)
- [프로젝트 지침](docs/project-guidelines.md)
- [변경 내역](docs/changelog.md)

5
app.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

51
assets/css/main.css Normal file
View File

@@ -0,0 +1,51 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
background: #f6f8f7;
color: #17211f;
}
body {
margin: 0;
min-height: 100vh;
font-feature-settings: "tnum";
}
input,
select,
textarea,
button {
font: inherit;
}
@layer components {
.field {
@apply w-full rounded-md border border-line bg-white px-3 py-3 text-base outline-none transition focus:border-brand-600 focus:ring-2 focus:ring-brand-100;
}
.label {
@apply mb-1 block text-sm font-medium text-muted;
}
.btn-primary {
@apply inline-flex min-h-12 items-center justify-center rounded-md bg-brand-700 px-4 py-3 text-base font-semibold text-white shadow-soft transition active:scale-[0.99];
}
.btn-secondary {
@apply inline-flex min-h-11 items-center justify-center rounded-md border border-line bg-white px-3 py-2 text-sm font-semibold text-ink transition active:scale-[0.99];
}
.segmented {
@apply grid grid-cols-2 rounded-md border border-line bg-white p-1;
}
.segmented-button {
@apply rounded px-3 py-2 text-sm font-semibold text-muted transition;
}
.segmented-button-active {
@apply bg-ink text-white;
}
}

View File

@@ -0,0 +1,110 @@
<template>
<div>
<button
type="button"
class="field flex min-h-[50px] items-center justify-between text-left"
aria-haspopup="dialog"
:aria-expanded="open"
@click="open = true"
>
<span :class="selected ? 'text-ink' : 'text-muted'">{{ selected?.label || placeholder }}</span>
<span class="text-xl leading-none text-muted">⌄</span>
</button>
<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="open" class="fixed inset-0 z-40 bg-ink/35" @click="open = false" />
</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="open"
class="fixed inset-x-0 bottom-0 z-50 mx-auto max-h-[78vh] max-w-lg overflow-hidden rounded-t-2xl bg-white shadow-2xl"
role="dialog"
:aria-label="title"
>
<div class="sticky top-0 border-b border-line bg-white px-4 pb-3 pt-2">
<div class="mx-auto mb-3 h-1 w-10 rounded-full bg-line" />
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold">{{ title }}</h2>
<button type="button" class="rounded-md px-2 py-1 text-sm font-semibold text-muted" @click="open = false">닫기</button>
</div>
</div>
<div class="max-h-[60vh] overflow-y-auto px-2 py-2">
<button
v-for="option in options"
:key="option.value"
type="button"
class="flex w-full items-center justify-between rounded-md px-3 py-4 text-left active:bg-paper"
@click="select(option.value)"
>
<span>
<span class="block font-semibold text-ink">{{ option.label }}</span>
<span v-if="option.description" class="mt-1 block text-sm text-muted">{{ option.description }}</span>
</span>
<span
class="ml-3 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-sm"
:class="option.value === modelValue ? 'border-brand-700 bg-brand-700 text-white' : 'border-line text-transparent'"
>
✓
</span>
</button>
</div>
</section>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
interface Option {
value: string
label: string
description?: string
}
const props = withDefaults(defineProps<{
modelValue: string
options: Option[]
title: string
placeholder?: string
}>(), {
placeholder: '선택'
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const open = ref(false)
const selected = computed(() => props.options.find(option => option.value === props.modelValue))
function select(value: string) {
emit('update:modelValue', value)
open.value = false
}
watch(open, (value) => {
if (import.meta.server) return
document.body.style.overflow = value ? 'hidden' : ''
})
onBeforeUnmount(() => {
if (import.meta.client) document.body.style.overflow = ''
})
</script>

View File

@@ -0,0 +1,25 @@
<template>
<button
type="button"
role="switch"
:aria-checked="modelValue"
class="relative inline-flex h-7 w-12 shrink-0 items-center rounded-full border transition"
:class="modelValue ? 'border-brand-700 bg-brand-700' : 'border-line bg-slate-200'"
@click="$emit('update:modelValue', !modelValue)"
>
<span
class="inline-block h-5 w-5 rounded-full bg-white shadow transition"
:class="modelValue ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
}>()
defineEmits<{
'update:modelValue': [value: boolean]
}>()
</script>

52
composables/useMoney.ts Normal file
View File

@@ -0,0 +1,52 @@
export type TransactionType = 'income' | 'expense'
export type AssetBalanceType = 'cumulative' | 'monthly_reset'
export interface Asset {
id: string
name: string
balanceType: AssetBalanceType
initialBalance: number
active: boolean
sortOrder: number
balance?: number
}
export interface Category {
id: string
type: TransactionType
name: string
parentId: string | null
sortOrder: number
active: boolean
parent?: Category | null
children?: Category[]
}
export interface Transaction {
id: string
type: TransactionType
occurredAt: string
assetId: string
categoryId: string
amount: number
title: string
memo: string | null
latitude: string | null
longitude: string | null
asset: Asset
category: Category & { parent?: Category | null }
}
export function useMoneyApi() {
const refresh = () => refreshNuxtData()
return {
refresh,
assets: () => useFetch<Asset[]>('/api/assets'),
categories: () => useFetch<Category[]>('/api/categories'),
transactions: (month: Ref<string>) =>
useFetch<{ summary: { income: number; expense: number; diff: number }; transactions: Transaction[] }>('/api/transactions', {
query: { month }
})
}
}

29
docker-compose.yml Normal file
View File

@@ -0,0 +1,29 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: money
POSTGRES_USER: money
POSTGRES_PASSWORD: money
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
app:
build: .
restart: unless-stopped
depends_on:
- db
environment:
DATABASE_URL: postgresql://money:money@db:5432/money?schema=public
SESSION_SECRET: change-this-secret-in-production
ports:
- "3000:3000"
volumes:
- uploads_data:/app/uploads
volumes:
postgres_data:
uploads_data:

26
docs/architecture.md Normal file
View File

@@ -0,0 +1,26 @@
# Architecture
## Stack
- Nuxt 3 + Vue 3 + TypeScript
- Tailwind CSS
- Prisma ORM
- PostgreSQL
- Docker Compose
- PWA via `@vite-pwa/nuxt`
## Routes
- `/login`: 로그인
- `/`: 월별 거래 내역
- `/add`: 수입/지출 입력
- `/stats`: 월별 요약과 카테고리 합계
- `/settings`: 자산/카테고리 관리
## Data Principles
- 거래 데이터가 금액 계산의 Source of Truth입니다.
- 자산 잔액은 DB에 현재값으로 저장하지 않고 거래 합계로 계산합니다.
- `cumulative` 자산은 최초 잔액과 전체 거래 합계로 계산합니다.
- `monthly_reset` 자산은 조회 중인 월의 거래만 계산합니다.
- 카테고리와 자산은 삭제보다 비활성화를 우선합니다.

14
docs/changelog.md Normal file
View File

@@ -0,0 +1,14 @@
# 변경 내역
## 2026-08-13
- Nuxt 3, Vue 3, TypeScript, Tailwind CSS 기반 프로젝트를 생성했다.
- Prisma, PostgreSQL, Docker Compose 기반 DB 구성을 추가했다.
- PWA manifest와 앱 아이콘을 추가했다.
- 로그인, 자산, 카테고리, 거래 CRUD API의 기본 골격을 추가했다.
- 모바일 하단 탭, 바텀 시트 선택 UI, 플로팅 추가 버튼을 적용했다.
- 거래 목록, 거래 추가, 거래 상세/수정, 통계, 자산, 설정 화면을 추가했다.
- 통계 화면은 월별 수입/지출 분류 집계 중심으로 정리했다.
- 자산 화면은 자산 목록과 자산별 월 거래 내역으로 분리했다.
- 설정 화면은 항목 목록에서 자산/카테고리 관리 상세로 들어가는 구조로 변경했다.
- PC 화면에서는 모바일 고정 폭을 제거하고 사이드바와 넓은 콘텐츠 영역을 사용하도록 전역 레이아웃을 수정했다.

View File

@@ -0,0 +1,40 @@
# 프로젝트 지침
## 제품 방향
- 모바일 입력을 가장 중요하게 본다.
- 모바일 전용 고정 폭 앱으로 만들지 않는다.
- PC에서는 넓은 작업영역, 사이드바, 표/목록 밀도를 활용한다.
- 기능은 회계 앱처럼 복잡하게 확장하지 않는다.
- 거래 데이터가 금액 계산의 Source of Truth다.
## UI 기준
- 모바일: 하단 탭, 플로팅 추가 버튼, 바텀 시트 선택 UI를 우선한다.
- PC: 사이드바 내비게이션과 넓은 콘텐츠 영역을 사용한다.
- 입력 화면은 한 화면에서 빠르게 끝나는 흐름을 유지한다.
- 셀렉트 박스보다 모바일 친화적인 바텀 시트를 우선한다.
- 활성/비활성 같은 상태는 텍스트 버튼보다 토글처럼 현재 상태가 보이는 UI를 쓴다.
- 통계 화면은 그래프보다 숫자와 분류별 리스트를 우선한다.
## 데이터 기준
- PostgreSQL을 기본 DB로 사용한다.
- Prisma migration은 저장소에 포함한다.
- 자산 잔액은 현재값을 저장하지 않고 거래 합계로 계산한다.
- 카테고리와 자산은 완전 삭제보다 비활성화를 우선한다.
- CSV Export는 데이터 보존을 위한 필수 기능으로 유지한다.
## 개발/운영 기준
- 주요 수정 후 `npm run build`로 검증한다.
- 개발 서버가 켜져 있어 빌드 락이 걸리면 `NUXT_IGNORE_LOCK=1 npm run build`로 검증한다.
- git 커밋 사용자 정보는 아래 값을 사용한다.
```text
user.name: zenn
user.email: zenn.message@gmail.com
```
- 의미 있는 수정 단위마다 변경 내역을 문서에 남기고 커밋/푸시한다.
- 원격 저장소는 `https://git.sori.studio/zenn/money.sori.studio.git`를 사용한다.

1046
docs/requirements.md Normal file

File diff suppressed because it is too large Load Diff

70
layouts/default.vue Normal file
View File

@@ -0,0 +1,70 @@
<template>
<div class="min-h-screen bg-paper text-ink">
<aside
v-if="showNav"
class="fixed inset-y-0 left-0 z-20 hidden w-64 border-r border-line bg-white md:block"
>
<div class="flex h-16 items-center gap-3 border-b border-line px-5">
<div class="flex h-9 w-9 items-center justify-center rounded-md bg-red-500 text-lg font-bold text-white">M</div>
<strong>Money 가계부</strong>
</div>
<nav class="px-3 py-5">
<NuxtLink
v-for="item in items"
:key="item.to"
:to="item.to"
class="mb-2 flex min-h-12 items-center gap-3 rounded-md px-4 text-sm font-semibold text-muted"
active-class="bg-red-50 text-red-600"
>
<span class="text-lg leading-none">{{ item.icon }}</span>
<span>{{ item.label }}</span>
</NuxtLink>
</nav>
</aside>
<main class="min-h-screen w-full bg-white pb-24 md:ml-64 md:bg-paper md:pb-0">
<div class="mx-auto min-h-screen w-full md:max-w-7xl md:bg-transparent">
<slot />
</div>
</main>
<nav
v-if="showNav"
class="fixed inset-x-0 bottom-0 z-20 border-t border-line bg-white/95 backdrop-blur md:hidden"
>
<div class="mx-auto grid max-w-lg grid-cols-4">
<NuxtLink
v-for="item in items"
:key="item.to"
:to="item.to"
class="flex min-h-16 flex-col items-center justify-center gap-1 text-xs font-medium text-muted"
active-class="text-brand-700"
>
<span class="text-xl leading-none">{{ item.icon }}</span>
<span>{{ item.label }}</span>
</NuxtLink>
</div>
</nav>
<NuxtLink
v-if="showFab"
to="/add"
class="fixed bottom-24 right-4 z-30 flex h-16 w-16 items-center justify-center rounded-full bg-red-500 text-4xl leading-none text-white shadow-soft active:scale-95 md:bottom-10 md:right-10"
aria-label="거래 추가"
>
+
</NuxtLink>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const items = [
{ to: '/', label: '내역', icon: '≡' },
{ to: '/stats', label: '통계', icon: '∑' },
{ to: '/assets', label: '자산', icon: '◎' },
{ to: '/settings', label: '설정', icon: '⚙' }
]
const showNav = computed(() => route.path !== '/login')
const showFab = computed(() => showNav.value && route.path !== '/add' && !route.path.startsWith('/transactions/'))
</script>

17
middleware/auth.global.ts Normal file
View File

@@ -0,0 +1,17 @@
export default defineNuxtRouteMiddleware(async (to) => {
if (to.path === '/login') return
const session = useState<{ id: string; email: string; name: string } | null>('session')
if (session.value === undefined) {
try {
session.value = await $fetch('/api/auth/me')
} catch {
session.value = null
}
}
if (!session.value) {
return navigateTo('/login')
}
})

55
nuxt.config.ts Normal file
View File

@@ -0,0 +1,55 @@
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: false },
modules: ['@nuxtjs/tailwindcss', '@vite-pwa/nuxt'],
css: ['~/assets/css/main.css'],
runtimeConfig: {
databaseUrl: process.env.DATABASE_URL,
sessionSecret: process.env.SESSION_SECRET || 'change-this-secret-in-production',
public: {
appName: 'Money'
}
},
vite: {
server: {
watch: {
ignored: ['**/node_modules/**', '**/.npm-cache/**', '**/.nuxt/**', '**/.output/**', '**/prisma/migrations/**']
}
}
},
watchers: {
chokidar: {
ignored: ['**/node_modules/**', '**/.npm-cache/**', '**/.nuxt/**', '**/.output/**', '**/prisma/migrations/**']
}
},
pwa: {
registerType: 'autoUpdate',
manifest: {
name: 'Money 가계부',
short_name: 'Money',
description: '빠르게 기록하고 기간별로 확인하는 개인 가계부',
theme_color: '#0f766e',
background_color: '#ffffff',
display: 'standalone',
scope: '/',
start_url: '/',
icons: [
{
src: '/icons/icon-192.svg',
sizes: '192x192',
type: 'image/svg+xml',
purpose: 'any'
},
{
src: '/icons/icon-512.svg',
sizes: '512x512',
type: 'image/svg+xml',
purpose: 'any maskable'
}
]
},
workbox: {
navigateFallback: '/'
}
}
})

15791
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "money",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev --host 0.0.0.0",
"generate": "nuxt generate",
"preview": "nuxt preview --host 0.0.0.0",
"postinstall": "nuxt prepare",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"db:seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@vite-pwa/nuxt": "^1.0.4",
"bcryptjs": "^3.0.2",
"date-fns": "^4.1.0",
"nuxt": "^3.17.7",
"prisma": "^6.13.0",
"@prisma/client": "^6.13.0",
"vue": "^3.5.18",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
"tsx": "^4.20.3",
"typescript": "^5.8.3"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}

150
pages/add.vue Normal file
View File

@@ -0,0 +1,150 @@
<template>
<section class="px-4 py-4">
<h1 class="mb-4 text-xl font-bold">거래 추가</h1>
<form class="space-y-4" @submit.prevent="submit">
<div class="segmented">
<button type="button" class="segmented-button" :class="{ 'segmented-button-active': form.type === 'income' }" @click="form.type = 'income'">수입</button>
<button type="button" class="segmented-button" :class="{ 'segmented-button-active': form.type === 'expense' }" @click="form.type = 'expense'">지출</button>
</div>
<div class="grid grid-cols-[1fr_auto] gap-2">
<div>
<label class="label" for="occurredAt">날짜</label>
<input id="occurredAt" v-model="form.occurredAt" class="field" type="datetime-local" required>
</div>
<div>
<span class="label">반복</span>
<button type="button" class="btn-secondary h-[50px]" disabled>없음</button>
</div>
</div>
<div>
<label class="label">자산</label>
<BottomSheetSelect
v-model="form.assetId"
title="자산 선택"
placeholder="자산 선택"
:options="assetOptions"
/>
</div>
<div>
<label class="label">분류</label>
<BottomSheetSelect
v-model="form.categoryId"
title="분류 선택"
placeholder="분류 선택"
:options="categoryOptions"
/>
</div>
<div>
<label class="label" for="amount">금액</label>
<input id="amount" v-model.number="form.amount" class="field text-right text-xl font-bold" inputmode="numeric" min="1" type="number" required>
</div>
<div>
<label class="label" for="title">내용</label>
<input id="title" v-model="form.title" class="field" placeholder="이마트칠성점" required>
</div>
<div>
<div class="mb-1 flex items-center justify-between">
<span class="label mb-0">위치</span>
<div class="flex gap-2">
<button type="button" class="text-sm font-semibold text-brand-700" @click="captureLocation">가져오기</button>
<button v-if="form.latitude" type="button" class="text-sm font-semibold text-muted" @click="clearLocation">삭제</button>
</div>
</div>
<p class="rounded-md border border-line bg-paper px-3 py-3 text-sm text-muted">
{{ form.latitude ? `${form.latitude}, ${form.longitude}` : '선택 사항입니다.' }}
</p>
</div>
<div>
<label class="label" for="memo">메모</label>
<textarea id="memo" v-model="form.memo" class="field min-h-28 resize-none" placeholder="자유롭게 기록"></textarea>
</div>
<button class="btn-primary sticky bottom-20 w-full" type="submit" :disabled="pending">
{{ pending ? '저장 중...' : '저장하기' }}
</button>
</form>
</section>
</template>
<script setup lang="ts">
const pending = ref(false)
const { data: assetsData } = await useFetch('/api/assets')
const { data: categoriesData } = await useFetch('/api/categories')
const form = reactive({
type: 'expense',
occurredAt: formatDateInput(),
assetId: '',
categoryId: '',
amount: 0,
title: '',
memo: '',
latitude: null as number | null,
longitude: null as number | null
})
const assets = computed(() => assetsData.value || [])
const categories = computed(() => categoriesData.value || [])
const filteredCategories = computed(() => categories.value.filter((category: any) => category.type === form.type && category.active))
const assetOptions = computed(() => assets.value.map((asset: any) => ({
value: asset.id,
label: asset.name,
description: asset.balanceType === 'cumulative' ? '계속 누적' : '매월 새로 시작'
})))
const categoryOptions = computed(() => filteredCategories.value.map((category: any) => ({
value: category.id,
label: category.parent ? `${category.parent.name} > ${category.name}` : category.name,
description: category.type === 'income' ? '수입' : '지출'
})))
watch(assets, (items: any[]) => {
if (!form.assetId && items[0]) form.assetId = items[0].id
}, { immediate: true })
watch(filteredCategories, (items: any[]) => {
if (!items.some(item => item.id === form.categoryId)) form.categoryId = items[0]?.id || ''
}, { immediate: true })
function captureLocation() {
if (!navigator.geolocation) return
navigator.geolocation.getCurrentPosition((position) => {
form.latitude = Number(position.coords.latitude.toFixed(7))
form.longitude = Number(position.coords.longitude.toFixed(7))
})
}
function clearLocation() {
form.latitude = null
form.longitude = null
}
async function submit() {
pending.value = true
await $fetch('/api/transactions', {
method: 'POST',
body: form
})
const keepAssetId = form.assetId
Object.assign(form, {
type: form.type,
occurredAt: formatDateInput(),
assetId: keepAssetId,
categoryId: form.categoryId,
amount: 0,
title: '',
memo: '',
latitude: null,
longitude: null
})
pending.value = false
await navigateTo('/')
}
</script>

239
pages/assets.vue Normal file
View File

@@ -0,0 +1,239 @@
<template>
<section class="min-h-screen bg-paper">
<header class="sticky top-0 z-10 border-b border-line bg-white">
<div class="flex h-16 items-center justify-between px-4">
<div class="flex items-center gap-2">
<button
v-if="selectedAsset"
type="button"
class="flex h-10 w-10 items-center justify-center rounded-md text-3xl leading-none active:bg-paper"
aria-label="자산 목록으로"
@click="selectedAssetId = ''"
>
‹
</button>
<h1 class="text-xl font-bold">{{ selectedAsset?.name || '자산' }}</h1>
</div>
<div class="flex items-center gap-1">
<button class="flex h-10 w-9 items-center justify-center rounded-md text-3xl leading-none active:bg-paper" type="button" @click="moveMonth(-1)">‹</button>
<button class="rounded-md px-2 py-2 text-lg font-semibold active:bg-paper" type="button" @click="monthPickerOpen = true">
{{ displayMonth }}
</button>
<button class="flex h-10 w-9 items-center justify-center rounded-md text-3xl leading-none active:bg-paper" type="button" @click="moveMonth(1)">›</button>
</div>
</div>
<div v-if="!selectedAsset" class="grid grid-cols-3 border-t border-line px-4 py-4 text-center">
<div>
<p class="text-sm font-semibold text-muted">누적형</p>
<p class="mt-1 font-bold text-brand-700">{{ formatWon(cumulativeTotal) }}</p>
</div>
<div>
<p class="text-sm font-semibold text-muted">월별</p>
<p class="mt-1 font-bold text-red-600">{{ formatWon(monthlyTotal) }}</p>
</div>
<div>
<p class="text-sm font-semibold text-muted">합계</p>
<p class="mt-1 font-bold">{{ formatWon(totalBalance) }}</p>
</div>
</div>
</header>
<div v-if="!selectedAsset" class="divide-y divide-line border-b border-line bg-white">
<button
v-for="asset in assets"
:key="asset.id"
type="button"
class="flex w-full items-center justify-between px-4 py-5 text-left active:bg-paper"
@click="selectedAssetId = asset.id"
>
<span>
<span class="block text-lg font-semibold text-ink">{{ asset.name }}</span>
<span class="mt-1 block text-sm text-muted">{{ asset.balanceType === 'cumulative' ? '계속 누적' : `${displayMonth}만 계산` }}</span>
</span>
<span class="text-xl font-bold">{{ formatWon(asset.balance || 0) }}</span>
</button>
<p v-if="!assets.length" class="px-4 py-12 text-center text-muted">등록된 자산이 없습니다.</p>
</div>
<div v-else>
<section class="border-b border-line bg-white px-4 py-5">
<p class="text-sm font-semibold text-muted">현재 표시 잔액</p>
<p class="mt-1 text-3xl font-bold">{{ formatWon(selectedAsset.balance || 0) }}</p>
<div class="mt-5 grid grid-cols-3 text-center">
<div>
<p class="text-sm text-muted">입금</p>
<p class="mt-1 font-bold text-brand-700">{{ formatWon(assetSummary.income) }}</p>
</div>
<div>
<p class="text-sm text-muted">출금</p>
<p class="mt-1 font-bold text-red-600">{{ formatWon(assetSummary.expense) }}</p>
</div>
<div>
<p class="text-sm text-muted">합계</p>
<p class="mt-1 font-bold">{{ formatWon(assetSummary.diff) }}</p>
</div>
</div>
</section>
<div class="divide-y divide-line bg-white">
<section v-for="group in groupedTransactions" :key="group.date" class="py-2">
<div class="flex items-center justify-between px-4 py-2">
<div class="flex items-center gap-2">
<strong class="text-3xl">{{ group.day }}</strong>
<span class="rounded bg-slate-200 px-2 py-1 text-sm font-semibold text-muted">{{ group.weekday }}</span>
<span class="text-sm text-muted">{{ group.monthLabel }}</span>
</div>
<div class="flex gap-4 text-sm font-bold">
<span class="text-brand-700">{{ formatWon(group.income) }}</span>
<span class="text-red-600">{{ formatWon(group.expense) }}</span>
</div>
</div>
<NuxtLink
v-for="tx in group.items"
:key="tx.id"
:to="`/transactions/${tx.id}`"
class="grid grid-cols-[1fr_auto] gap-3 px-4 py-3"
>
<div class="min-w-0">
<p class="truncate font-semibold">{{ tx.title }}</p>
<p class="mt-1 truncate text-sm text-muted">{{ categoryLabel(tx.category) }}</p>
</div>
<div class="text-right">
<p class="font-bold" :class="tx.type === 'income' ? 'text-brand-700' : 'text-red-600'">
{{ formatWon(tx.amount) }}
</p>
<p class="mt-1 text-sm text-muted">{{ timeLabel(tx.occurredAt) }}</p>
</div>
</NuxtLink>
</section>
<p v-if="!assetTransactions.length" class="px-4 py-12 text-center text-muted">
이 자산의 {{ displayMonth }} 거래가 없습니다.
</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="monthPickerOpen" class="fixed inset-0 z-40 bg-ink/35" @click="monthPickerOpen = false" />
</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="monthPickerOpen" class="fixed inset-x-0 bottom-0 z-50 mx-auto max-w-lg rounded-t-2xl bg-white shadow-2xl">
<div class="border-b border-line px-4 pb-3 pt-2">
<div class="mx-auto mb-3 h-1 w-10 rounded-full bg-line" />
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold">월 선택</h2>
<button type="button" class="text-sm font-semibold text-muted" @click="monthPickerOpen = false">닫기</button>
</div>
</div>
<div class="flex items-center justify-between px-4 py-4">
<button class="text-3xl" type="button" @click="pickerYear--">‹</button>
<strong class="text-2xl">{{ pickerYear }}</strong>
<button class="text-3xl" type="button" @click="pickerYear++">›</button>
</div>
<div class="grid grid-cols-4 gap-y-2 px-4 pb-6">
<button
v-for="month in 12"
:key="month"
type="button"
class="rounded-md py-4 text-lg font-bold"
:class="isSelectedPickerMonth(month) ? 'bg-red-50 text-red-500' : 'active:bg-paper'"
@click="selectPickerMonth(month)"
>
{{ month }}월
</button>
</div>
</section>
</Transition>
</Teleport>
</section>
</template>
<script setup lang="ts">
import { addMonths, format, parse } from 'date-fns'
import type { Category, Transaction } from '~/composables/useMoney'
const selectedMonth = ref(monthKey())
const selectedAssetId = ref('')
const monthPickerOpen = ref(false)
const pickerYear = ref(Number(selectedMonth.value.slice(0, 4)))
const { data: assetData } = await useFetch('/api/assets', { query: { month: selectedMonth } })
const { data: transactionData } = await useFetch('/api/transactions', { query: { month: selectedMonth } })
const assets = computed(() => assetData.value || [])
const selectedAsset = computed(() => assets.value.find((asset: any) => asset.id === selectedAssetId.value))
const displayMonth = computed(() => {
const date = parse(selectedMonth.value, 'yyyy-MM', new Date())
return format(date, 'yyyy년 M월')
})
const cumulativeTotal = computed(() => assets.value.filter((asset: any) => asset.balanceType === 'cumulative').reduce((sum: number, asset: any) => sum + (asset.balance || 0), 0))
const monthlyTotal = computed(() => assets.value.filter((asset: any) => asset.balanceType === 'monthly_reset').reduce((sum: number, asset: any) => sum + (asset.balance || 0), 0))
const totalBalance = computed(() => assets.value.reduce((sum: number, asset: any) => sum + (asset.balance || 0), 0))
const assetTransactions = computed(() => {
return (transactionData.value?.transactions || []).filter((tx: Transaction) => tx.assetId === selectedAssetId.value)
})
const assetSummary = computed(() => {
return assetTransactions.value.reduce((acc: any, tx: Transaction) => {
acc[tx.type] += tx.amount
acc.diff = acc.income - acc.expense
return acc
}, { income: 0, expense: 0, diff: 0 })
})
const groupedTransactions = computed(() => {
const groups = new Map<string, any>()
for (const tx of assetTransactions.value) {
const date = new Date(tx.occurredAt)
const key = format(date, 'yyyy-MM-dd')
const group = groups.get(key) || {
date: key,
day: format(date, 'd'),
weekday: new Intl.DateTimeFormat('ko-KR', { weekday: 'short' }).format(date),
monthLabel: format(date, 'yyyy.MM'),
income: 0,
expense: 0,
items: []
}
group[tx.type] += tx.amount
group.items.push(tx)
groups.set(key, group)
}
return Array.from(groups.values()).sort((a, b) => b.date.localeCompare(a.date))
})
watch(monthPickerOpen, (open) => {
if (open) pickerYear.value = Number(selectedMonth.value.slice(0, 4))
})
watch(monthPickerOpen, (open) => {
if (import.meta.server) return
document.body.style.overflow = open ? 'hidden' : ''
})
onBeforeUnmount(() => {
if (import.meta.client) document.body.style.overflow = ''
})
function moveMonth(amount: number) {
const date = parse(selectedMonth.value, 'yyyy-MM', new Date())
selectedMonth.value = format(addMonths(date, amount), 'yyyy-MM')
}
function isSelectedPickerMonth(month: number) {
return selectedMonth.value === `${pickerYear.value}-${String(month).padStart(2, '0')}`
}
function selectPickerMonth(month: number) {
selectedMonth.value = `${pickerYear.value}-${String(month).padStart(2, '0')}`
monthPickerOpen.value = false
}
function timeLabel(value: string) {
return new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit' }).format(new Date(value))
}
function categoryLabel(category: Category & { parent?: Category | null }) {
return category.parent ? `${category.parent.name} > ${category.name}` : category.name
}
</script>

77
pages/index.vue Normal file
View File

@@ -0,0 +1,77 @@
<template>
<section>
<header class="sticky top-0 z-10 border-b border-line bg-white px-4 pb-3 pt-4">
<div class="flex items-center justify-between">
<button class="btn-secondary min-h-10 px-3" @click="moveMonth(-1)">‹</button>
<input v-model="selectedMonth" class="w-36 rounded-md border border-line px-3 py-2 text-center font-semibold" type="month">
<button class="btn-secondary min-h-10 px-3" @click="moveMonth(1)">›</button>
</div>
<div class="mt-4 grid grid-cols-3 gap-2 text-sm">
<div>
<p class="text-muted">수입</p>
<p class="font-bold text-brand-700">{{ formatWon(data?.summary.income || 0) }}</p>
</div>
<div>
<p class="text-muted">지출</p>
<p class="font-bold text-red-600">{{ formatWon(data?.summary.expense || 0) }}</p>
</div>
<div>
<p class="text-muted">차액</p>
<p class="font-bold">{{ formatWon(data?.summary.diff || 0) }}</p>
</div>
</div>
</header>
<div class="divide-y divide-line">
<NuxtLink
v-for="tx in data?.transactions"
:key="tx.id"
:to="`/transactions/${tx.id}`"
class="grid grid-cols-[1fr_auto] gap-3 px-4 py-3"
>
<div class="min-w-0">
<p class="truncate font-semibold text-ink">{{ tx.title }}</p>
<p class="mt-1 text-xs text-muted">
{{ timeLabel(tx.occurredAt) }} · {{ tx.asset.name }}
</p>
<p class="mt-1 truncate text-xs text-muted">
{{ categoryLabel(tx.category) }}
</p>
</div>
<div class="text-right">
<p class="font-bold" :class="tx.type === 'income' ? 'text-brand-700' : 'text-red-600'">
{{ tx.type === 'income' ? '+' : '-' }}{{ formatWon(tx.amount) }}
</p>
</div>
</NuxtLink>
</div>
<div v-if="!data?.transactions.length" class="px-4 py-16 text-center text-muted">
아직 거래가 없습니다.
</div>
</section>
</template>
<script setup lang="ts">
import { addMonths, format, parse } from 'date-fns'
import type { Category } from '~/composables/useMoney'
const selectedMonth = ref(monthKey())
const { data } = await useFetch('/api/transactions', {
query: { month: selectedMonth }
})
function moveMonth(amount: number) {
const date = parse(selectedMonth.value, 'yyyy-MM', new Date())
selectedMonth.value = format(addMonths(date, amount), 'yyyy-MM')
}
function timeLabel(value: string) {
return new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit' }).format(new Date(value))
}
function categoryLabel(category: Category & { parent?: Category | null }) {
return category.parent ? `${category.parent.name} > ${category.name}` : category.name
}
</script>

48
pages/login.vue Normal file
View File

@@ -0,0 +1,48 @@
<template>
<section class="flex min-h-screen flex-col justify-center px-5 py-10">
<div class="mb-8">
<p class="text-sm font-semibold text-brand-700">Money</p>
<h1 class="mt-2 text-3xl font-bold tracking-normal text-ink">빠르게 기록하는 개인 가계부</h1>
<p class="mt-3 text-sm leading-6 text-muted">홈 화면에 설치해서 앱처럼 쓰고, 로그인 상태를 유지할 수 있게 구성했습니다.</p>
</div>
<form class="space-y-4" @submit.prevent="submit">
<div>
<label class="label" for="email">이메일</label>
<input id="email" v-model="email" class="field" type="email" autocomplete="email" required>
</div>
<div>
<label class="label" for="password">비밀번호</label>
<input id="password" v-model="password" class="field" type="password" autocomplete="current-password" required>
</div>
<p v-if="error" class="text-sm font-medium text-red-600">{{ error }}</p>
<button class="btn-primary w-full" type="submit" :disabled="pending">
{{ pending ? '확인 중...' : '로그인' }}
</button>
</form>
</section>
</template>
<script setup lang="ts">
const email = ref('user@example.com')
const password = ref('money1234')
const error = ref('')
const pending = ref(false)
const session = useState('session')
async function submit() {
pending.value = true
error.value = ''
try {
session.value = await $fetch('/api/auth/login', {
method: 'POST',
body: { email: email.value, password: password.value }
})
await navigateTo('/')
} catch {
error.value = '이메일 또는 비밀번호를 확인해 주세요.'
} finally {
pending.value = false
}
}
</script>

184
pages/settings.vue Normal file
View File

@@ -0,0 +1,184 @@
<template>
<section class="min-h-screen bg-paper">
<header class="sticky top-0 z-10 flex h-16 items-center gap-3 border-b border-line bg-white px-4">
<button
v-if="view !== 'home'"
type="button"
class="flex h-10 w-10 items-center justify-center rounded-md text-3xl leading-none active:bg-paper"
aria-label="뒤로"
@click="view = 'home'"
>
‹
</button>
<h1 class="text-xl font-bold">{{ title }}</h1>
</header>
<div v-if="view === 'home'">
<div class="border-b border-line bg-paper px-4 py-4 text-sm font-semibold text-muted">관리</div>
<div class="divide-y divide-line bg-white">
<button
v-for="item in menuItems"
:key="item.id"
type="button"
class="flex w-full items-center justify-between px-4 py-5 text-left active:bg-paper"
@click="open(item.id)"
>
<span>
<span class="block text-lg font-semibold text-ink">{{ item.label }}</span>
<span class="mt-1 block text-sm text-muted">{{ item.description }}</span>
</span>
<span class="text-2xl text-muted">›</span>
</button>
</div>
<div class="mt-3 border-y border-line bg-white">
<a
class="flex items-center justify-between px-4 py-5 active:bg-paper"
href="/api/export/transactions.csv"
>
<span>
<span class="block text-lg font-semibold text-ink">CSV 내보내기</span>
<span class="mt-1 block text-sm text-muted">전체 거래 데이터를 파일로 저장합니다.</span>
</span>
<span class="text-2xl text-muted">↓</span>
</a>
</div>
</div>
<div v-else-if="view === 'assets'" class="px-4 py-4">
<form class="space-y-3 border-b border-line pb-5" @submit.prevent="createAsset">
<input v-model="assetForm.name" class="field border-x-0 border-t-0 px-0" 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 border-x-0 border-t-0 px-0 text-right" inputmode="numeric" type="number" placeholder="최초 잔액">
<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="asset in assets" :key="asset.id" class="flex items-center justify-between gap-3 py-4">
<div>
<p class="font-semibold">{{ asset.name }}</p>
<p class="mt-1 text-xs text-muted">
{{ asset.balanceType === 'cumulative' ? '계속 누적' : '매월 새로 시작' }} · {{ asset.active ? '활성' : '비활성' }}
</p>
</div>
<ToggleSwitch :model-value="asset.active" @update:model-value="toggleAsset(asset, $event)" />
</div>
</div>
</div>
<div v-else class="px-4 py-4">
<form class="space-y-3 border-b border-line pb-5" @submit.prevent="createCategory">
<div class="segmented">
<button type="button" class="segmented-button" :class="{ 'segmented-button-active': categoryForm.type === 'income' }" @click="categoryForm.type = 'income'">수입</button>
<button type="button" class="segmented-button" :class="{ 'segmented-button-active': categoryForm.type === 'expense' }" @click="categoryForm.type = 'expense'">지출</button>
</div>
<BottomSheetSelect
v-model="categoryForm.parentId"
title="상위 분류 선택"
placeholder="대분류로 만들기"
:options="parentSheetOptions"
/>
<input v-model="categoryForm.name" class="field border-x-0 border-t-0 px-0" placeholder="카테고리 이름" required>
<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="category in categories" :key="category.id" class="flex items-center justify-between gap-3 py-4">
<div>
<p class="font-semibold">{{ category.parent ? `${category.parent.name} > ${category.name}` : category.name }}</p>
<p class="mt-1 text-xs text-muted">
{{ category.type === 'income' ? '수입' : '지출' }} · {{ category.active ? '활성' : '비활성' }}
</p>
</div>
<ToggleSwitch :model-value="category.active" @update:model-value="toggleCategory(category, $event)" />
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
type SettingsView = 'home' | 'assets' | 'categories'
const view = ref<SettingsView>('home')
const { data: assetData, refresh: refreshAssets } = await useFetch('/api/assets', { query: { includeInactive: true } })
const { data: categoryData, refresh: refreshCategories } = await useFetch('/api/categories', { query: { includeInactive: true } })
const menuItems = [
{ id: 'assets', label: '자산 관리', description: '자산 추가, 잔액 계산 방식, 활성 상태' },
{ id: 'categories', label: '카테고리 관리', description: '수입/지출 분류 추가와 활성 상태' }
] as const
const assetForm = reactive({
name: '',
balanceType: 'cumulative',
initialBalance: 0
})
const categoryForm = reactive({
type: 'expense',
parentId: '',
name: ''
})
const title = computed(() => {
if (view.value === 'assets') return '자산 관리'
if (view.value === 'categories') return '카테고리 관리'
return '설정'
})
const assets = computed(() => assetData.value || [])
const categories = computed(() => categoryData.value || [])
const parentOptions = computed(() => categories.value.filter((category: any) => category.type === categoryForm.type && !category.parentId && category.active))
const parentSheetOptions = computed(() => [
{ value: '', label: '대분류로 만들기', description: '상위 분류 없이 저장합니다.' },
...parentOptions.value.map((category: any) => ({
value: category.id,
label: category.name,
description: category.type === 'income' ? '수입' : '지출'
}))
])
function open(nextView: SettingsView) {
view.value = nextView
}
async function createAsset() {
await $fetch('/api/assets', { method: 'POST', body: assetForm })
Object.assign(assetForm, { name: '', balanceType: 'cumulative', initialBalance: 0 })
await refreshAssets()
}
async function toggleAsset(asset: any, active: boolean) {
await $fetch(`/api/assets/${asset.id}`, { method: 'PATCH', body: { active } })
await refreshAssets()
}
async function createCategory() {
await $fetch('/api/categories', { method: 'POST', body: categoryForm })
Object.assign(categoryForm, { type: categoryForm.type, parentId: '', name: '' })
await refreshCategories()
}
async function toggleCategory(category: any, active: boolean) {
await $fetch(`/api/categories/${category.id}`, { method: 'PATCH', body: { active } })
await refreshCategories()
}
</script>

210
pages/stats.vue Normal file
View File

@@ -0,0 +1,210 @@
<template>
<section class="min-h-screen bg-paper">
<header class="sticky top-0 z-10 border-b border-line bg-white">
<div class="flex h-16 items-center justify-between px-4">
<div class="flex items-center gap-2">
<button class="flex h-10 w-10 items-center justify-center rounded-md text-3xl leading-none active:bg-paper" type="button" @click="moveMonth(-1)">‹</button>
<button class="rounded-md px-1 py-2 text-left text-2xl font-semibold active:bg-paper" type="button" @click="monthPickerOpen = true">
{{ displayMonth }}
</button>
<button class="flex h-10 w-10 items-center justify-center rounded-md text-3xl leading-none active:bg-paper" type="button" @click="moveMonth(1)">›</button>
</div>
<button class="btn-secondary min-h-10 px-3 text-base" type="button" @click="periodSheetOpen = true">
월간 ⌄
</button>
</div>
<div class="grid grid-cols-2 border-t border-line">
<button
type="button"
class="relative px-4 py-4 text-center text-lg font-bold"
:class="selectedType === 'income' ? 'text-brand-700' : 'text-muted'"
@click="selectedType = 'income'"
>
수입 {{ formatWon(summary.income) }}
<span v-if="selectedType === 'income'" class="absolute inset-x-0 bottom-0 h-1 bg-brand-700" />
</button>
<button
type="button"
class="relative px-4 py-4 text-center text-lg font-bold"
:class="selectedType === 'expense' ? 'text-red-600' : 'text-muted'"
@click="selectedType = 'expense'"
>
지출 {{ formatWon(summary.expense) }}
<span v-if="selectedType === 'expense'" class="absolute inset-x-0 bottom-0 h-1 bg-red-500" />
</button>
</div>
</header>
<div class="bg-white px-4 py-5">
<div class="flex items-end justify-between">
<div>
<p class="text-sm font-semibold text-muted">{{ selectedType === 'income' ? '수입 합계' : '지출 합계' }}</p>
<p class="mt-1 text-3xl font-bold">{{ formatWon(selectedTotal) }}</p>
</div>
<div class="text-right">
<p class="text-sm font-semibold text-muted">차액</p>
<p class="mt-1 text-lg font-bold" :class="summary.diff >= 0 ? 'text-brand-700' : 'text-red-600'">
{{ formatWon(summary.diff) }}
</p>
</div>
</div>
</div>
<div class="mt-3 divide-y divide-line border-y border-line bg-white">
<button
v-for="item in categoryTotals"
:key="item.id"
type="button"
class="flex w-full items-center gap-3 px-4 py-4 text-left active:bg-paper"
>
<span
class="flex h-9 w-16 shrink-0 items-center justify-center rounded-md text-sm font-bold text-white"
:class="selectedType === 'income' ? 'bg-brand-600' : 'bg-red-500'"
>
{{ item.percent }}%
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-lg font-semibold">{{ item.name }}</span>
<span v-if="item.children.length" class="mt-1 block truncate text-sm text-muted">
{{ item.children.map((child: any) => child.name).join(' · ') }}
</span>
</span>
<span class="shrink-0 text-lg font-semibold">{{ formatWon(item.total) }}</span>
</button>
<p v-if="!categoryTotals.length" class="px-4 py-12 text-center text-muted">
{{ selectedType === 'income' ? '집계할 수입이 없습니다.' : '집계할 지출이 없습니다.' }}
</p>
</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="monthPickerOpen || periodSheetOpen" class="fixed inset-0 z-40 bg-ink/35" @click="closeSheets" />
</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="monthPickerOpen" class="fixed inset-x-0 bottom-0 z-50 mx-auto max-w-lg rounded-t-2xl bg-white shadow-2xl">
<div class="border-b border-line px-4 pb-3 pt-2">
<div class="mx-auto mb-3 h-1 w-10 rounded-full bg-line" />
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold">월 선택</h2>
<button type="button" class="text-sm font-semibold text-muted" @click="monthPickerOpen = false">닫기</button>
</div>
</div>
<div class="flex items-center justify-between px-4 py-4">
<button class="text-3xl" type="button" @click="pickerYear--">‹</button>
<strong class="text-2xl">{{ pickerYear }}</strong>
<button class="text-3xl" type="button" @click="pickerYear++">›</button>
</div>
<div class="grid grid-cols-4 gap-y-2 px-4 pb-6">
<button
v-for="month in 12"
:key="month"
type="button"
class="rounded-md py-4 text-lg font-bold"
:class="isSelectedPickerMonth(month) ? 'bg-red-50 text-red-500' : 'active:bg-paper'"
@click="selectPickerMonth(month)"
>
{{ month }}월
</button>
</div>
</section>
</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="periodSheetOpen" class="fixed inset-x-0 bottom-0 z-50 mx-auto max-w-lg rounded-t-2xl bg-white shadow-2xl">
<div class="border-b border-line px-4 pb-3 pt-2">
<div class="mx-auto mb-3 h-1 w-10 rounded-full bg-line" />
<h2 class="text-lg font-bold">조회 단위</h2>
</div>
<div class="px-2 py-2">
<button class="flex w-full items-center justify-between rounded-md px-3 py-4 text-left active:bg-paper" type="button" @click="periodSheetOpen = false">
<span>
<span class="block font-semibold">월간</span>
<span class="mt-1 block text-sm text-muted">현재 구현된 조회 단위입니다.</span>
</span>
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-brand-700 text-white">✓</span>
</button>
<div class="px-3 py-4 text-sm text-muted">분기, 반기, 연도는 다음 단계에서 같은 화면 구조로 확장합니다.</div>
</div>
</section>
</Transition>
</Teleport>
</section>
</template>
<script setup lang="ts">
import { addMonths, format, parse } from 'date-fns'
type StatType = 'income' | 'expense'
const selectedMonth = ref(monthKey())
const selectedType = ref<StatType>('expense')
const monthPickerOpen = ref(false)
const periodSheetOpen = ref(false)
const pickerYear = ref(Number(selectedMonth.value.slice(0, 4)))
const { data } = await useFetch('/api/transactions', { query: { month: selectedMonth } })
const summary = computed(() => data.value?.summary || { income: 0, expense: 0, diff: 0 })
const displayMonth = computed(() => {
const date = parse(selectedMonth.value, 'yyyy-MM', new Date())
return format(date, 'yyyy년 M월')
})
const selectedTotal = computed(() => selectedType.value === 'income' ? summary.value.income : summary.value.expense)
const categoryTotals = computed(() => {
const map = new Map<string, any>()
for (const tx of data.value?.transactions || []) {
if (tx.type !== selectedType.value) continue
const parent = tx.category.parent || tx.category
const current = map.get(parent.id) || { id: parent.id, name: parent.name, total: 0, children: new Map() }
current.total += tx.amount
if (tx.category.parent) {
const child = current.children.get(tx.category.id) || { id: tx.category.id, name: tx.category.name, total: 0 }
child.total += tx.amount
current.children.set(tx.category.id, child)
}
map.set(parent.id, current)
}
return Array.from(map.values())
.map(item => ({
...item,
percent: selectedTotal.value ? Math.round((item.total / selectedTotal.value) * 100) : 0,
children: Array.from(item.children.values())
}))
.sort((a, b) => b.total - a.total)
})
watch(monthPickerOpen, (open) => {
if (open) pickerYear.value = Number(selectedMonth.value.slice(0, 4))
})
watch([monthPickerOpen, periodSheetOpen], ([monthOpen, periodOpen]) => {
if (import.meta.server) return
document.body.style.overflow = monthOpen || periodOpen ? 'hidden' : ''
})
onBeforeUnmount(() => {
if (import.meta.client) document.body.style.overflow = ''
})
function moveMonth(amount: number) {
const date = parse(selectedMonth.value, 'yyyy-MM', new Date())
selectedMonth.value = format(addMonths(date, amount), 'yyyy-MM')
}
function isSelectedPickerMonth(month: number) {
return selectedMonth.value === `${pickerYear.value}-${String(month).padStart(2, '0')}`
}
function selectPickerMonth(month: number) {
selectedMonth.value = `${pickerYear.value}-${String(month).padStart(2, '0')}`
monthPickerOpen.value = false
}
function closeSheets() {
monthPickerOpen.value = false
periodSheetOpen.value = false
}
</script>

196
pages/transactions/[id].vue Normal file
View File

@@ -0,0 +1,196 @@
<template>
<section class="min-h-screen bg-paper">
<header class="sticky top-0 z-10 flex h-16 items-center justify-between border-b border-line bg-white px-4">
<div class="flex items-center gap-3">
<button
type="button"
class="flex h-10 w-10 items-center justify-center rounded-md text-3xl leading-none active:bg-paper"
aria-label="뒤로"
@click="goBack"
>
‹
</button>
<h1 class="text-xl font-bold">{{ form.type === 'income' ? '수입' : '지출' }}</h1>
</div>
<button type="button" class="text-sm font-bold text-brand-700" :disabled="pending" @click="save">
저장
</button>
</header>
<form class="bg-white px-4 py-5" @submit.prevent="save">
<div class="mb-5 grid grid-cols-2 gap-2">
<button type="button" class="segmented-button border border-line py-3" :class="{ 'border-brand-700 text-brand-700': form.type === 'income' }" @click="setType('income')">수입</button>
<button type="button" class="segmented-button border border-line py-3" :class="{ 'border-red-500 text-red-500': form.type === 'expense' }" @click="setType('expense')">지출</button>
</div>
<div class="divide-y divide-line border-y border-line">
<div class="grid grid-cols-[5rem_1fr] items-center gap-3 py-4">
<label class="text-lg font-semibold text-muted" for="occurredAt">날짜</label>
<input id="occurredAt" v-model="form.occurredAt" class="w-full bg-transparent py-2 text-lg font-semibold outline-none" type="datetime-local" required>
</div>
<div class="grid grid-cols-[5rem_1fr] items-center gap-3 py-4">
<span class="text-lg font-semibold text-muted">자산</span>
<BottomSheetSelect
v-model="form.assetId"
title="자산 선택"
placeholder="자산 선택"
:options="assetOptions"
/>
</div>
<div class="grid grid-cols-[5rem_1fr] items-center gap-3 py-4">
<span class="text-lg font-semibold text-muted">분류</span>
<BottomSheetSelect
v-model="form.categoryId"
title="분류 선택"
placeholder="분류 선택"
:options="categoryOptions"
/>
</div>
<div class="grid grid-cols-[5rem_1fr] items-center gap-3 py-4">
<label class="text-lg font-semibold text-muted" for="amount">금액</label>
<input id="amount" v-model.number="form.amount" class="w-full bg-transparent py-2 text-right text-2xl font-bold outline-none" inputmode="numeric" min="1" type="number" required>
</div>
<div class="grid grid-cols-[5rem_1fr] items-center gap-3 py-4">
<label class="text-lg font-semibold text-muted" for="title">내용</label>
<input id="title" v-model="form.title" class="w-full bg-transparent py-2 text-lg font-semibold outline-none" required>
</div>
</div>
<div class="mt-4 border-y border-line py-4">
<label class="mb-2 block text-lg font-semibold text-muted" for="memo">메모</label>
<textarea id="memo" v-model="form.memo" class="min-h-24 w-full resize-none bg-transparent text-lg outline-none" placeholder="메모"></textarea>
</div>
<div v-if="locationLabel" class="mt-3 flex items-center justify-between border-b border-line py-4">
<a class="font-semibold text-brand-700" :href="mapsUrl" target="_blank" rel="noreferrer">위치 보기</a>
<button type="button" class="text-sm font-semibold text-muted" @click="clearLocation">위치 삭제</button>
</div>
<div class="mt-6 grid grid-cols-3 gap-2">
<button type="button" class="btn-secondary gap-2" @click="remove">삭제</button>
<button type="button" class="btn-secondary gap-2" @click="copy">복사</button>
<button type="button" class="btn-secondary gap-2" disabled>자주사용</button>
</div>
<button class="btn-primary mt-6 w-full" type="submit" :disabled="pending">
{{ pending ? '저장 중...' : '저장하기' }}
</button>
</form>
</section>
</template>
<script setup lang="ts">
type TransactionType = 'income' | 'expense'
const route = useRoute()
const router = useRouter()
const id = computed(() => String(route.params.id))
const pending = ref(false)
const { data: transaction } = await useFetch<any>(() => `/api/transactions/${id.value}`)
const { data: assetsData } = await useFetch('/api/assets')
const { data: categoriesData } = await useFetch('/api/categories')
const form = reactive({
type: 'expense' as TransactionType,
occurredAt: formatDateInput(),
assetId: '',
categoryId: '',
amount: 0,
title: '',
memo: '',
latitude: null as number | null,
longitude: null as number | null
})
const assets = computed(() => assetsData.value || [])
const categories = computed(() => categoriesData.value || [])
const filteredCategories = computed(() => categories.value.filter((category: any) => category.type === form.type && category.active))
const assetOptions = computed(() => assets.value.map((asset: any) => ({
value: asset.id,
label: asset.name,
description: asset.balanceType === 'cumulative' ? '계속 누적' : '매월 새로 시작'
})))
const categoryOptions = computed(() => filteredCategories.value.map((category: any) => ({
value: category.id,
label: category.parent ? `${category.parent.name} > ${category.name}` : category.name,
description: category.type === 'income' ? '수입' : '지출'
})))
const locationLabel = computed(() => form.latitude && form.longitude)
const mapsUrl = computed(() => `https://www.google.com/maps?q=${form.latitude},${form.longitude}`)
watch(transaction, (value) => {
if (!value) return
Object.assign(form, {
type: value.type,
occurredAt: formatDateInput(new Date(value.occurredAt)),
assetId: value.assetId,
categoryId: value.categoryId,
amount: value.amount,
title: value.title,
memo: value.memo || '',
latitude: value.latitude ? Number(value.latitude) : null,
longitude: value.longitude ? Number(value.longitude) : null
})
}, { immediate: true })
watch(filteredCategories, (items: any[]) => {
if (!items.some(item => item.id === form.categoryId)) form.categoryId = items[0]?.id || ''
})
function setType(type: TransactionType) {
form.type = type
}
function clearLocation() {
form.latitude = null
form.longitude = null
}
function payload() {
return {
type: form.type,
occurredAt: form.occurredAt,
assetId: form.assetId,
categoryId: form.categoryId,
amount: form.amount,
title: form.title,
memo: form.memo,
latitude: form.latitude,
longitude: form.longitude
}
}
async function save() {
pending.value = true
await $fetch(`/api/transactions/${id.value}`, {
method: 'PATCH',
body: payload()
})
pending.value = false
await router.back()
}
async function remove() {
if (!confirm('이 거래를 삭제할까요?')) return
await $fetch(`/api/transactions/${id.value}`, { method: 'DELETE' })
await navigateTo('/')
}
async function copy() {
pending.value = true
const created = await $fetch<any>('/api/transactions', {
method: 'POST',
body: { ...payload(), occurredAt: formatDateInput() }
})
pending.value = false
await navigateTo(`/transactions/${created.id}`)
}
function goBack() {
router.back()
}
</script>

View File

@@ -0,0 +1,155 @@
-- CreateEnum
CREATE TYPE "TransactionType" AS ENUM ('income', 'expense');
-- CreateEnum
CREATE TYPE "AssetBalanceType" AS ENUM ('cumulative', 'monthly_reset');
-- CreateEnum
CREATE TYPE "RecurrenceFrequency" AS ENUM ('none', 'daily', 'weekly', 'monthly', 'yearly');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Asset" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"balanceType" "AssetBalanceType" NOT NULL DEFAULT 'cumulative',
"initialBalance" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Asset_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Category" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" "TransactionType" NOT NULL,
"name" TEXT NOT NULL,
"parentId" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Transaction" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" "TransactionType" NOT NULL,
"occurredAt" TIMESTAMP(3) NOT NULL,
"assetId" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"latitude" DECIMAL(10,7),
"longitude" DECIMAL(10,7),
"memo" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Transaction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TransactionAttachment" (
"id" TEXT NOT NULL,
"transactionId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"filePath" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TransactionAttachment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RecurringTransaction" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" "TransactionType" NOT NULL,
"assetId" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"memo" TEXT,
"frequency" "RecurrenceFrequency" NOT NULL DEFAULT 'none',
"interval" INTEGER NOT NULL DEFAULT 1,
"repeatCount" INTEGER,
"endDate" TIMESTAMP(3),
"nextRunAt" TIMESTAMP(3),
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RecurringTransaction_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "Asset_userId_active_sortOrder_idx" ON "Asset"("userId", "active", "sortOrder");
-- CreateIndex
CREATE INDEX "Category_userId_type_active_sortOrder_idx" ON "Category"("userId", "type", "active", "sortOrder");
-- CreateIndex
CREATE INDEX "Category_parentId_idx" ON "Category"("parentId");
-- CreateIndex
CREATE INDEX "Transaction_userId_occurredAt_idx" ON "Transaction"("userId", "occurredAt");
-- CreateIndex
CREATE INDEX "Transaction_assetId_idx" ON "Transaction"("assetId");
-- CreateIndex
CREATE INDEX "Transaction_categoryId_idx" ON "Transaction"("categoryId");
-- CreateIndex
CREATE INDEX "TransactionAttachment_transactionId_idx" ON "TransactionAttachment"("transactionId");
-- CreateIndex
CREATE INDEX "RecurringTransaction_userId_active_nextRunAt_idx" ON "RecurringTransaction"("userId", "active", "nextRunAt");
-- AddForeignKey
ALTER TABLE "Asset" ADD CONSTRAINT "Asset_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Category" ADD CONSTRAINT "Category_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Category" ADD CONSTRAINT "Category_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TransactionAttachment" ADD CONSTRAINT "TransactionAttachment_transactionId_fkey" FOREIGN KEY ("transactionId") REFERENCES "Transaction"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RecurringTransaction" ADD CONSTRAINT "RecurringTransaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

139
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,139 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum TransactionType {
income
expense
}
enum AssetBalanceType {
cumulative
monthly_reset
}
enum RecurrenceFrequency {
none
daily
weekly
monthly
yearly
}
model User {
id String @id @default(cuid())
email String @unique
name String
passwordHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assets Asset[]
categories Category[]
transactions Transaction[]
recurringTransactions RecurringTransaction[]
}
model Asset {
id String @id @default(cuid())
userId String
name String
balanceType AssetBalanceType @default(cumulative)
initialBalance Int @default(0)
active Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
transactions Transaction[]
@@index([userId, active, sortOrder])
}
model Category {
id String @id @default(cuid())
userId String
type TransactionType
name String
parentId String?
sortOrder Int @default(0)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
children Category[] @relation("CategoryTree")
transactions Transaction[]
@@index([userId, type, active, sortOrder])
@@index([parentId])
}
model Transaction {
id String @id @default(cuid())
userId String
type TransactionType
occurredAt DateTime
assetId String
categoryId String
amount Int
title String
latitude Decimal? @db.Decimal(10, 7)
longitude Decimal? @db.Decimal(10, 7)
memo String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
asset Asset @relation(fields: [assetId], references: [id], onDelete: Restrict)
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
attachments TransactionAttachment[]
@@index([userId, occurredAt])
@@index([assetId])
@@index([categoryId])
}
model TransactionAttachment {
id String @id @default(cuid())
transactionId String
fileName String
filePath String
mimeType String
size Int
createdAt DateTime @default(now())
transaction Transaction @relation(fields: [transactionId], references: [id], onDelete: Cascade)
@@index([transactionId])
}
model RecurringTransaction {
id String @id @default(cuid())
userId String
type TransactionType
assetId String
categoryId String
amount Int
title String
memo String?
frequency RecurrenceFrequency @default(none)
interval Int @default(1)
repeatCount Int?
endDate DateTime?
nextRunAt DateTime?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, active, nextRunAt])
}

96
prisma/seed.ts Normal file
View File

@@ -0,0 +1,96 @@
import { hash } from 'bcryptjs'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
const user = await prisma.user.upsert({
where: { email: 'user@example.com' },
update: {},
create: {
email: 'user@example.com',
name: '사용자',
passwordHash: await hash('money1234', 10)
}
})
const cash = await prisma.asset.upsert({
where: { id: 'seed-cash' },
update: {},
create: {
id: 'seed-cash',
userId: user.id,
name: '현금',
balanceType: 'cumulative',
initialBalance: 0,
sortOrder: 0
}
})
await prisma.asset.upsert({
where: { id: 'seed-living' },
update: {},
create: {
id: 'seed-living',
userId: user.id,
name: '생활비',
balanceType: 'monthly_reset',
sortOrder: 1
}
})
const incomeParent = await prisma.category.upsert({
where: { id: 'seed-income-work' },
update: {},
create: { id: 'seed-income-work', userId: user.id, type: 'income', name: '근로', sortOrder: 0 }
})
const salary = await prisma.category.upsert({
where: { id: 'seed-income-salary' },
update: {},
create: { id: 'seed-income-salary', userId: user.id, type: 'income', name: '월급', parentId: incomeParent.id, sortOrder: 0 }
})
const foodParent = await prisma.category.upsert({
where: { id: 'seed-expense-food' },
update: {},
create: { id: 'seed-expense-food', userId: user.id, type: 'expense', name: '식비', sortOrder: 0 }
})
const meal = await prisma.category.upsert({
where: { id: 'seed-expense-meal' },
update: {},
create: { id: 'seed-expense-meal', userId: user.id, type: 'expense', name: '외식', parentId: foodParent.id, sortOrder: 0 }
})
const transactionCount = await prisma.transaction.count({ where: { userId: user.id } })
if (transactionCount === 0) {
await prisma.transaction.createMany({
data: [
{
userId: user.id,
type: 'income',
occurredAt: new Date(),
assetId: cash.id,
categoryId: salary.id,
amount: 3200000,
title: '월급'
},
{
userId: user.id,
type: 'expense',
occurredAt: new Date(),
assetId: cash.id,
categoryId: meal.id,
amount: 14980,
title: '이마트칠성점'
}
]
})
}
}
main()
.finally(async () => {
await prisma.$disconnect()
})

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192" role="img" aria-label="Money">
<rect width="192" height="192" fill="#0f766e" rx="36"/>
<path fill="#fff" d="M48 58h96c8.8 0 16 7.2 16 16v44c0 8.8-7.2 16-16 16H48c-8.8 0-16-7.2-16-16V74c0-8.8 7.2-16 16-16Zm8 24a16 16 0 0 1-16 16v20h112V98a16 16 0 0 1-16-16H56Zm40 38c13.3 0 24-10.7 24-24S109.3 72 96 72 72 82.7 72 96s10.7 24 24 24Z"/>
<circle cx="96" cy="96" r="10" fill="#0f766e"/>
</svg>

After

Width:  |  Height:  |  Size: 482 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="Money">
<rect width="512" height="512" fill="#0f766e" rx="96"/>
<path fill="#fff" d="M128 155h256c23.6 0 43 19.4 43 43v117c0 23.6-19.4 43-43 43H128c-23.6 0-43-19.4-43-43V198c0-23.6 19.4-43 43-43Zm21 64c0 23.6-19.4 43-43 43v53h300v-53c-23.6 0-43-19.4-43-43H149Zm107 101c35.4 0 64-28.6 64-64s-28.6-64-64-64-64 28.6-64 64 28.6 64 64 64Z"/>
<circle cx="256" cy="256" r="27" fill="#0f766e"/>
</svg>

After

Width:  |  Height:  |  Size: 510 B

View File

@@ -0,0 +1,22 @@
import { readBody, 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 body = await readBody<{ name?: string; balanceType?: 'cumulative' | 'monthly_reset'; initialBalance?: number; active?: boolean }>(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' })
return prisma.asset.update({
where: { id },
data: {
...(body.name !== undefined ? { name: body.name.trim() } : {}),
...(body.balanceType !== undefined ? { balanceType: body.balanceType } : {}),
...(body.initialBalance !== undefined ? { initialBalance: Number(body.initialBalance) } : {}),
...(body.active !== undefined ? { active: body.active } : {})
}
})
})

View File

@@ -0,0 +1,39 @@
import { getQuery } from 'h3'
import { startOfMonth, endOfMonth } from 'date-fns'
import { prisma } from '../../utils/db'
import { requireUser } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const query = getQuery(event)
const includeInactive = query.includeInactive === 'true'
const month = typeof query.month === 'string' ? query.month : new Date().toISOString().slice(0, 7)
const monthStart = startOfMonth(new Date(`${month}-01T00:00:00`))
const monthEnd = endOfMonth(monthStart)
const assets = await prisma.asset.findMany({
where: { userId: user.id, ...(includeInactive ? {} : { active: true }) },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }]
})
const balances = await Promise.all(assets.map(async (asset) => {
const where = {
userId: user.id,
assetId: asset.id,
...(asset.balanceType === 'monthly_reset'
? { occurredAt: { gte: monthStart, lte: monthEnd } }
: {})
}
const [income, expense] = await Promise.all([
prisma.transaction.aggregate({ where: { ...where, type: 'income' }, _sum: { amount: true } }),
prisma.transaction.aggregate({ where: { ...where, type: 'expense' }, _sum: { amount: true } })
])
const base = asset.balanceType === 'cumulative' ? asset.initialBalance : 0
return {
...asset,
balance: base + (income._sum.amount || 0) - (expense._sum.amount || 0)
}
}))
return balances
})

View File

@@ -0,0 +1,19 @@
import { readBody } 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 count = await prisma.asset.count({ where: { userId: user.id } })
return prisma.asset.create({
data: {
userId: user.id,
name: body.name.trim(),
balanceType: body.balanceType,
initialBalance: body.balanceType === 'cumulative' ? Number(body.initialBalance || 0) : 0,
sortOrder: count
}
})
})

View File

@@ -0,0 +1,16 @@
import { compare } from 'bcryptjs'
import { readBody, createError } from 'h3'
import { prisma } from '../../utils/db'
import { setSession } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const body = await readBody<{ email: string; password: string }>(event)
const user = await prisma.user.findUnique({ where: { email: body.email } })
if (!user || !(await compare(body.password, user.passwordHash))) {
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
}
setSession(event, user.id)
return { id: user.id, email: user.email, name: user.name }
})

View File

@@ -0,0 +1,6 @@
import { clearAuthSession } from '../../utils/auth'
export default defineEventHandler((event) => {
clearAuthSession(event)
return { ok: true }
})

View File

@@ -0,0 +1,5 @@
import { requireUser } from '../../utils/auth'
export default defineEventHandler(async (event) => {
return requireUser(event)
})

View File

@@ -0,0 +1,21 @@
import { readBody, 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 body = await readBody<{ name?: string; active?: boolean; sortOrder?: number }>(event)
const category = await prisma.category.findFirst({ where: { id, userId: user.id } })
if (!category) throw createError({ statusCode: 404, statusMessage: 'Category not found' })
return prisma.category.update({
where: { id },
data: {
...(body.name !== undefined ? { name: body.name.trim() } : {}),
...(body.active !== undefined ? { active: body.active } : {}),
...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {})
}
})
})

View File

@@ -0,0 +1,15 @@
import { getQuery } from 'h3'
import { prisma } from '../../utils/db'
import { requireUser } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const query = getQuery(event)
const includeInactive = query.includeInactive === 'true'
return prisma.category.findMany({
where: { userId: user.id, ...(includeInactive ? {} : { active: true }) },
include: { parent: true },
orderBy: [{ type: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }]
})
})

View File

@@ -0,0 +1,27 @@
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<{ type: 'income' | 'expense'; name: string; parentId?: string }>(event)
if (body.parentId) {
const parent = await prisma.category.findFirst({ where: { id: body.parentId, userId: user.id } })
if (!parent || parent.parentId) {
throw createError({ statusCode: 400, statusMessage: 'Only two category levels are allowed' })
}
}
const count = await prisma.category.count({ where: { userId: user.id, type: body.type, parentId: body.parentId || null } })
return prisma.category.create({
data: {
userId: user.id,
type: body.type,
name: body.name.trim(),
parentId: body.parentId || null,
sortOrder: count
}
})
})

View File

@@ -0,0 +1,33 @@
import { setHeader } from 'h3'
import { prisma } from '../../utils/db'
import { requireUser } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const rows = await prisma.transaction.findMany({
where: { userId: user.id },
include: { asset: true, category: { include: { parent: true } } },
orderBy: { occurredAt: 'desc' }
})
const escape = (value: unknown) => `"${String(value ?? '').replaceAll('"', '""')}"`
const lines = [
['id', 'type', 'occurredAt', 'asset', 'category', 'amount', 'title', 'memo', 'latitude', 'longitude'].map(escape).join(','),
...rows.map(row => [
row.id,
row.type,
row.occurredAt.toISOString(),
row.asset.name,
row.category.parent ? `${row.category.parent.name} > ${row.category.name}` : row.category.name,
row.amount,
row.title,
row.memo,
row.latitude,
row.longitude
].map(escape).join(','))
]
setHeader(event, 'content-type', 'text/csv; charset=utf-8')
setHeader(event, 'content-disposition', 'attachment; filename="transactions.csv"')
return lines.join('\n')
})

View File

@@ -0,0 +1,12 @@
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 transaction = await prisma.transaction.findFirst({ where: { id, userId: user.id } })
if (!transaction) throw createError({ statusCode: 404, statusMessage: 'Transaction not found' })
await prisma.transaction.delete({ where: { id } })
return { ok: true }
})

View File

@@ -0,0 +1,21 @@
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 transaction = await prisma.transaction.findFirst({
where: { id, userId: user.id },
include: {
asset: true,
category: { include: { parent: true } }
}
})
if (!transaction) {
throw createError({ statusCode: 404, statusMessage: 'Transaction not found' })
}
return transaction
})

View File

@@ -0,0 +1,54 @@
import { readBody, 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 body = await readBody<{
type: 'income' | 'expense'
occurredAt: string
assetId: string
categoryId: string
amount: number
title: string
memo?: string
latitude?: number | null
longitude?: number | null
}>(event)
const transaction = await prisma.transaction.findFirst({ where: { id, userId: user.id } })
if (!transaction) throw createError({ statusCode: 404, statusMessage: 'Transaction not found' })
if (!body.title?.trim() || !body.amount || body.amount < 1) {
throw createError({ statusCode: 400, statusMessage: 'Invalid transaction' })
}
const [asset, category] = await Promise.all([
prisma.asset.findFirst({ where: { id: body.assetId, userId: user.id, active: true } }),
prisma.category.findFirst({ where: { id: body.categoryId, userId: user.id, type: body.type, active: true } })
])
if (!asset || !category) {
throw createError({ statusCode: 400, statusMessage: 'Invalid asset or category' })
}
return prisma.transaction.update({
where: { id },
data: {
type: body.type,
occurredAt: new Date(body.occurredAt),
assetId: body.assetId,
categoryId: body.categoryId,
amount: Number(body.amount),
title: body.title.trim(),
memo: body.memo?.trim() || null,
latitude: body.latitude ?? null,
longitude: body.longitude ?? null
},
include: {
asset: true,
category: { include: { parent: true } }
}
})
})

View File

@@ -0,0 +1,32 @@
import { getQuery } from 'h3'
import { startOfMonth, endOfMonth } from 'date-fns'
import { prisma } from '../../utils/db'
import { requireUser } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const query = getQuery(event)
const month = typeof query.month === 'string' ? query.month : new Date().toISOString().slice(0, 7)
const monthStart = startOfMonth(new Date(`${month}-01T00:00:00`))
const monthEnd = endOfMonth(monthStart)
const transactions = await prisma.transaction.findMany({
where: {
userId: user.id,
occurredAt: { gte: monthStart, lte: monthEnd }
},
include: {
asset: true,
category: { include: { parent: true } }
},
orderBy: { occurredAt: 'desc' }
})
const summary = transactions.reduce((acc, tx) => {
acc[tx.type] += tx.amount
acc.diff = acc.income - acc.expense
return acc
}, { income: 0, expense: 0, diff: 0 })
return { summary, transactions }
})

View File

@@ -0,0 +1,46 @@
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<{
type: 'income' | 'expense'
occurredAt: string
assetId: string
categoryId: string
amount: number
title: string
memo?: string
latitude?: number | null
longitude?: number | null
}>(event)
if (!body.title?.trim() || !body.amount || body.amount < 1) {
throw createError({ statusCode: 400, statusMessage: 'Invalid transaction' })
}
const [asset, category] = await Promise.all([
prisma.asset.findFirst({ where: { id: body.assetId, userId: user.id, active: true } }),
prisma.category.findFirst({ where: { id: body.categoryId, userId: user.id, type: body.type, active: true } })
])
if (!asset || !category) {
throw createError({ statusCode: 400, statusMessage: 'Invalid asset or category' })
}
return prisma.transaction.create({
data: {
userId: user.id,
type: body.type,
occurredAt: new Date(body.occurredAt),
assetId: body.assetId,
categoryId: body.categoryId,
amount: Number(body.amount),
title: body.title.trim(),
memo: body.memo?.trim() || null,
latitude: body.latitude ?? null,
longitude: body.longitude ?? null
}
})
})

53
server/utils/auth.ts Normal file
View File

@@ -0,0 +1,53 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import type { H3Event } from 'h3'
import { getCookie, setCookie, deleteCookie, createError } from 'h3'
import { prisma } from './db'
const cookieName = 'money_session'
function secret() {
return useRuntimeConfig().sessionSecret
}
function sign(value: string) {
return createHmac('sha256', secret()).update(value).digest('hex')
}
export function createSessionCookie(userId: string) {
const payload = Buffer.from(JSON.stringify({ userId })).toString('base64url')
return `${payload}.${sign(payload)}`
}
export function setSession(event: H3Event, userId: string) {
setCookie(event, cookieName, createSessionCookie(userId), {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 180,
path: '/'
})
}
export function clearAuthSession(event: H3Event) {
deleteCookie(event, cookieName, { path: '/' })
}
export async function requireUser(event: H3Event) {
const raw = getCookie(event, cookieName)
if (!raw) throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
const [payload, signature] = raw.split('.')
if (!payload || !signature) throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
const expected = sign(payload)
const isValid = timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
if (!isValid) throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { userId: string }
const user = await prisma.user.findUnique({
where: { id: parsed.userId },
select: { id: true, email: true, name: true }
})
if (!user) throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
return user
}

9
server/utils/db.ts Normal file
View File

@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
export const prisma = globalForPrisma.prisma || new PrismaClient()
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}

34
tailwind.config.ts Normal file
View File

@@ -0,0 +1,34 @@
import type { Config } from 'tailwindcss'
export default <Partial<Config>>{
theme: {
extend: {
colors: {
ink: '#17211f',
muted: '#6b7673',
line: '#dfe7e4',
paper: '#fbfcfb',
brand: {
50: '#ecfdf8',
100: '#d0f7ea',
500: '#14b8a6',
600: '#0d9488',
700: '#0f766e'
}
},
fontFamily: {
sans: [
'Inter',
'Pretendard',
'Apple SD Gothic Neo',
'Noto Sans KR',
'system-ui',
'sans-serif'
]
},
boxShadow: {
soft: '0 8px 30px rgba(23, 33, 31, 0.08)'
}
}
}
}

3
tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}

13
utils/format.ts Normal file
View File

@@ -0,0 +1,13 @@
export function formatWon(value: number) {
return `${Math.round(value).toLocaleString('ko-KR')}원`
}
export function formatDateInput(date = new Date()) {
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
export function monthKey(date = new Date()) {
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}`
}