first commit
This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal 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
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
.nuxt
|
||||
.output
|
||||
node_modules
|
||||
.npm-cache
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
uploads
|
||||
*.log
|
||||
20
Dockerfile
Normal file
20
Dockerfile
Normal 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
36
README.md
Normal 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
5
app.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
51
assets/css/main.css
Normal file
51
assets/css/main.css
Normal 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;
|
||||
}
|
||||
}
|
||||
110
components/BottomSheetSelect.vue
Normal file
110
components/BottomSheetSelect.vue
Normal 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>
|
||||
25
components/ToggleSwitch.vue
Normal file
25
components/ToggleSwitch.vue
Normal 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
52
composables/useMoney.ts
Normal 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
29
docker-compose.yml
Normal 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
26
docs/architecture.md
Normal 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
14
docs/changelog.md
Normal 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 화면에서는 모바일 고정 폭을 제거하고 사이드바와 넓은 콘텐츠 영역을 사용하도록 전역 레이아웃을 수정했다.
|
||||
40
docs/project-guidelines.md
Normal file
40
docs/project-guidelines.md
Normal 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
1046
docs/requirements.md
Normal file
File diff suppressed because it is too large
Load Diff
70
layouts/default.vue
Normal file
70
layouts/default.vue
Normal 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
17
middleware/auth.global.ts
Normal 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
55
nuxt.config.ts
Normal 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
15791
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal 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
150
pages/add.vue
Normal 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
239
pages/assets.vue
Normal 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
77
pages/index.vue
Normal 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
48
pages/login.vue
Normal 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
184
pages/settings.vue
Normal 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
210
pages/stats.vue
Normal 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
196
pages/transactions/[id].vue
Normal 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>
|
||||
155
prisma/migrations/20260813050004_init/migration.sql
Normal file
155
prisma/migrations/20260813050004_init/migration.sql
Normal 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;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
139
prisma/schema.prisma
Normal 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
96
prisma/seed.ts
Normal 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()
|
||||
})
|
||||
5
public/icons/icon-192.svg
Normal file
5
public/icons/icon-192.svg
Normal 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 |
5
public/icons/icon-512.svg
Normal file
5
public/icons/icon-512.svg
Normal 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 |
22
server/api/assets/[id].patch.ts
Normal file
22
server/api/assets/[id].patch.ts
Normal 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 } : {})
|
||||
}
|
||||
})
|
||||
})
|
||||
39
server/api/assets/index.get.ts
Normal file
39
server/api/assets/index.get.ts
Normal 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
|
||||
})
|
||||
19
server/api/assets/index.post.ts
Normal file
19
server/api/assets/index.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
16
server/api/auth/login.post.ts
Normal file
16
server/api/auth/login.post.ts
Normal 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 }
|
||||
})
|
||||
6
server/api/auth/logout.post.ts
Normal file
6
server/api/auth/logout.post.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clearAuthSession } from '../../utils/auth'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
clearAuthSession(event)
|
||||
return { ok: true }
|
||||
})
|
||||
5
server/api/auth/me.get.ts
Normal file
5
server/api/auth/me.get.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { requireUser } from '../../utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
return requireUser(event)
|
||||
})
|
||||
21
server/api/categories/[id].patch.ts
Normal file
21
server/api/categories/[id].patch.ts
Normal 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 } : {})
|
||||
}
|
||||
})
|
||||
})
|
||||
15
server/api/categories/index.get.ts
Normal file
15
server/api/categories/index.get.ts
Normal 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' }]
|
||||
})
|
||||
})
|
||||
27
server/api/categories/index.post.ts
Normal file
27
server/api/categories/index.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
33
server/api/export/transactions.csv.get.ts
Normal file
33
server/api/export/transactions.csv.get.ts
Normal 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')
|
||||
})
|
||||
12
server/api/transactions/[id].delete.ts
Normal file
12
server/api/transactions/[id].delete.ts
Normal 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 }
|
||||
})
|
||||
21
server/api/transactions/[id].get.ts
Normal file
21
server/api/transactions/[id].get.ts
Normal 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
|
||||
})
|
||||
54
server/api/transactions/[id].patch.ts
Normal file
54
server/api/transactions/[id].patch.ts
Normal 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 } }
|
||||
}
|
||||
})
|
||||
})
|
||||
32
server/api/transactions/index.get.ts
Normal file
32
server/api/transactions/index.get.ts
Normal 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 }
|
||||
})
|
||||
46
server/api/transactions/index.post.ts
Normal file
46
server/api/transactions/index.post.ts
Normal 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
53
server/utils/auth.ts
Normal 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
9
server/utils/db.ts
Normal 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
34
tailwind.config.ts
Normal 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
3
tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
13
utils/format.ts
Normal file
13
utils/format.ts
Normal 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)}`
|
||||
}
|
||||
Reference in New Issue
Block a user