Add read only sharing links
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<NuxtLink
|
||||
:to="`/transactions/${transaction.id}`"
|
||||
<component
|
||||
:is="rowComponent"
|
||||
v-bind="rowAttrs"
|
||||
class="grid grid-cols-[2.5rem_minmax(0,1fr)_auto] items-start gap-3 px-4 py-3 active:bg-paper md:px-5"
|
||||
>
|
||||
<div
|
||||
@@ -29,7 +30,7 @@
|
||||
>
|
||||
{{ formatWon(transaction.amount) }}
|
||||
</p>
|
||||
</NuxtLink>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -37,8 +38,11 @@ import type { Transaction } from '~/composables/useMoney'
|
||||
|
||||
const props = defineProps<{
|
||||
transaction: Transaction
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const rowComponent = computed(() => props.readonly ? 'div' : resolveComponent('NuxtLink'))
|
||||
const rowAttrs = computed(() => props.readonly ? {} : { to: `/transactions/${props.transaction.id}` })
|
||||
const categoryLabel = computed(() => {
|
||||
const category = props.transaction.category
|
||||
return category.name
|
||||
|
||||
@@ -37,3 +37,7 @@
|
||||
- 회원가입 API와 로그인 화면의 로그인/회원가입 전환 UI를 추가했다.
|
||||
- 가입 시 기본 자산과 기본 수입/지출 카테고리가 생성되도록 했다.
|
||||
- 읽기 전용 공유 링크 기능 계획을 TODO에 추가했다.
|
||||
- 읽기 전용 공유 링크 DB 모델과 migration을 추가했다.
|
||||
- 공유 링크 생성, 목록 조회, 삭제 API를 추가했다.
|
||||
- 만료 기간이 있는 공개 읽기 전용 공유 화면을 추가했다.
|
||||
- 설정 화면에 계정 관리, 로그아웃, 공유 링크 관리 화면을 추가했다.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
if (to.path === '/login') return
|
||||
if (to.path === '/login' || to.path.startsWith('/share/')) return
|
||||
|
||||
const session = useState<{ id: string; email: string; name: string } | null>('session')
|
||||
|
||||
|
||||
@@ -57,6 +57,65 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="view === 'account'" class="px-4 py-4">
|
||||
<section class="border-y border-line bg-white px-4 py-4">
|
||||
<p class="text-sm font-semibold text-muted">로그인 계정</p>
|
||||
<p class="mt-2 text-base font-bold">{{ session?.name }}</p>
|
||||
<p class="mt-1 text-sm text-muted">{{ session?.email }}</p>
|
||||
</section>
|
||||
|
||||
<button type="button" class="btn-secondary mt-4 w-full border-red-200 text-red-600" @click="logout">
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="view === 'sharing'" class="px-4 py-4">
|
||||
<form class="space-y-3 border-b border-line pb-5" @submit.prevent="createShareLink">
|
||||
<input v-model="shareForm.name" class="field" placeholder="공유 이름">
|
||||
<div>
|
||||
<span class="label">조회 가능 기간</span>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
v-for="days in shareDayOptions"
|
||||
:key="days"
|
||||
type="button"
|
||||
class="rounded-md border px-3 py-2 text-sm font-bold"
|
||||
:class="shareForm.days === days ? 'border-red-500 bg-red-50 text-red-600' : 'border-line text-muted'"
|
||||
@click="shareForm.days = days"
|
||||
>
|
||||
{{ days }}일
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<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="link in shareLinks" :key="link.id" class="px-4 py-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate text-base font-semibold text-ink">{{ link.name }}</span>
|
||||
<span class="mt-1 block text-xs text-muted">
|
||||
{{ link.active ? '공유 중' : '비활성' }} · {{ dateLabel(link.expiresAt) }}까지
|
||||
</span>
|
||||
<span class="mt-1 block truncate text-xs text-muted">{{ shareUrl(link.token) }}</span>
|
||||
</span>
|
||||
<span class="shrink-0 rounded-full px-2 py-1 text-xs font-bold" :class="link.active ? 'bg-brand-50 text-brand-700' : 'bg-paper text-muted'">
|
||||
{{ link.active ? 'ON' : 'OFF' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2">
|
||||
<button type="button" class="btn-secondary" :disabled="!link.active" @click="copyShareUrl(link.token)">복사</button>
|
||||
<button type="button" class="btn-secondary border-red-200 text-red-600" :disabled="!link.active" @click="revokeShareLink(link.id)">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!shareLinks.length" class="px-4 py-12 text-center text-muted">
|
||||
만든 공유 링크가 없습니다.
|
||||
</p>
|
||||
</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" placeholder="자산 이름" required>
|
||||
@@ -166,18 +225,24 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type SettingsView = 'home' | 'assets' | 'categories'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
type SettingsView = 'home' | 'account' | 'sharing' | 'assets' | 'categories'
|
||||
type CategoryType = 'income' | 'expense'
|
||||
|
||||
const view = ref<SettingsView>('home')
|
||||
const session = useState<{ id: string; email: string; name: string } | null>('session')
|
||||
const categoryType = ref<CategoryType>('expense')
|
||||
const selectedParentId = ref('')
|
||||
const addSheetOpen = ref(false)
|
||||
const categoryName = ref('')
|
||||
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 { data: shareLinkData, refresh: refreshShareLinks } = await useFetch('/api/share-links')
|
||||
|
||||
const menuItems = [
|
||||
{ id: 'account', label: '계정 관리', description: '로그인 계정 확인과 로그아웃' },
|
||||
{ id: 'sharing', label: '공유 링크 관리', description: '읽기 전용 공유 링크 생성과 삭제' },
|
||||
{ id: 'assets', label: '자산 관리', description: '자산 추가, 잔액 계산 방식, 활성 상태' },
|
||||
{ id: 'categories', label: '카테고리 관리', description: '수입/지출 상위 분류와 하위 분류' }
|
||||
] as const
|
||||
@@ -187,8 +252,15 @@ const assetForm = reactive({
|
||||
balanceType: 'cumulative',
|
||||
initialBalance: 0
|
||||
})
|
||||
const shareDayOptions = [1, 7, 30]
|
||||
const shareForm = reactive({
|
||||
name: '',
|
||||
days: 7
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
if (view.value === 'account') return '계정 관리'
|
||||
if (view.value === 'sharing') return '공유 링크 관리'
|
||||
if (view.value === 'assets') return '자산 관리'
|
||||
if (selectedParentCategory.value) return selectedParentCategory.value.name
|
||||
if (view.value === 'categories') return '카테고리 관리'
|
||||
@@ -196,6 +268,7 @@ const title = computed(() => {
|
||||
})
|
||||
const assets = computed(() => assetData.value || [])
|
||||
const categories = computed(() => categoryData.value || [])
|
||||
const shareLinks = computed(() => shareLinkData.value || [])
|
||||
const selectedParentCategory = computed(() => categories.value.find((category: any) => category.id === selectedParentId.value))
|
||||
const categoryTypeLabel = computed(() => categoryType.value === 'income' ? '수입' : '지출')
|
||||
const visibleCategories = computed(() => {
|
||||
@@ -269,4 +342,38 @@ async function createCategory() {
|
||||
addSheetOpen.value = false
|
||||
await refreshCategories()
|
||||
}
|
||||
|
||||
async function createShareLink() {
|
||||
await $fetch('/api/share-links', {
|
||||
method: 'POST',
|
||||
body: shareForm
|
||||
})
|
||||
Object.assign(shareForm, { name: '', days: 7 })
|
||||
await refreshShareLinks()
|
||||
}
|
||||
|
||||
async function revokeShareLink(id: string) {
|
||||
await $fetch(`/api/share-links/${id}`, { method: 'DELETE' })
|
||||
await refreshShareLinks()
|
||||
}
|
||||
|
||||
async function copyShareUrl(token: string) {
|
||||
if (!import.meta.client) return
|
||||
await navigator.clipboard?.writeText(shareUrl(token))
|
||||
}
|
||||
|
||||
function shareUrl(token: string) {
|
||||
if (!import.meta.client) return `/share/${token}`
|
||||
return `${window.location.origin}/share/${token}`
|
||||
}
|
||||
|
||||
function dateLabel(value: string) {
|
||||
return format(new Date(value), 'yyyy.MM.dd')
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||
session.value = null
|
||||
await navigateTo('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
106
pages/share/[token].vue
Normal file
106
pages/share/[token].vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<section class="min-h-dvh bg-paper">
|
||||
<header class="sticky top-0 z-10 border-b border-line bg-white px-4 py-4">
|
||||
<p class="text-xs font-bold text-red-500">읽기 전용 공유</p>
|
||||
<h1 class="mt-1 text-lg font-extrabold">{{ data?.name || '공유 가계부' }}</h1>
|
||||
<p v-if="data" class="mt-1 text-xs text-muted">
|
||||
{{ data.owner }}님의 {{ displayMonth }} 내역 · {{ expiresLabel }}까지
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="px-4 py-20 text-center">
|
||||
<h2 class="text-lg font-bold">공유 링크를 볼 수 없습니다.</h2>
|
||||
<p class="mt-2 text-sm text-muted">링크가 만료되었거나 삭제되었을 수 있습니다.</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<section class="border-b border-line bg-white px-4 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<button type="button" class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper" aria-label="이전 달" @click="moveMonth(-1)">
|
||||
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_left.svg" alt="">
|
||||
</button>
|
||||
<button type="button" class="text-lg font-bold" @click="monthPickerOpen = true">{{ displayMonth }}</button>
|
||||
<button type="button" class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper" aria-label="다음 달" @click="moveMonth(1)">
|
||||
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-3 gap-2 text-center text-sm">
|
||||
<div>
|
||||
<p class="text-muted">수입</p>
|
||||
<p class="mt-1 font-bold text-brand-700">{{ formatWon(data?.summary.income || 0) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted">지출</p>
|
||||
<p class="mt-1 font-bold text-red-600">{{ formatWon(data?.summary.expense || 0) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted">차액</p>
|
||||
<p class="mt-1 font-bold">{{ formatWon(data?.summary.diff || 0) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="divide-y divide-line bg-white">
|
||||
<section v-for="group in groupedTransactions" :key="group.date" class="py-2">
|
||||
<h2 class="px-4 py-3 text-sm font-semibold text-muted">
|
||||
{{ group.label }}
|
||||
</h2>
|
||||
<TransactionListRow
|
||||
v-for="tx in group.items"
|
||||
:key="tx.id"
|
||||
:transaction="tx"
|
||||
readonly
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p v-if="!data?.transactions.length" class="px-4 py-16 text-center text-muted">
|
||||
공유된 {{ displayMonth }} 거래가 없습니다.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<BottomSheetMonthPicker v-model="selectedMonth" v-model:open="monthPickerOpen" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { addMonths, format, parse } from 'date-fns'
|
||||
import type { Transaction } from '~/composables/useMoney'
|
||||
|
||||
definePageMeta({ layout: false })
|
||||
|
||||
const route = useRoute()
|
||||
const token = computed(() => String(route.params.token))
|
||||
const selectedMonth = ref(monthKey())
|
||||
const monthPickerOpen = ref(false)
|
||||
const { data, error } = await useFetch<any>(() => `/api/shared/${token.value}`, {
|
||||
query: { month: selectedMonth }
|
||||
})
|
||||
|
||||
const displayMonth = computed(() => {
|
||||
const date = parse(selectedMonth.value, 'yyyy-MM', new Date())
|
||||
return format(date, 'yyyy년 M월')
|
||||
})
|
||||
const expiresLabel = computed(() => {
|
||||
if (!data.value?.expiresAt) return ''
|
||||
return format(new Date(data.value.expiresAt), 'yyyy.MM.dd')
|
||||
})
|
||||
const groupedTransactions = computed(() => {
|
||||
const groups = new Map<string, { date: string; label: string; items: Transaction[] }>()
|
||||
for (const tx of data.value?.transactions || []) {
|
||||
const date = new Date(tx.occurredAt)
|
||||
const key = format(date, 'yyyy-MM-dd')
|
||||
const weekday = new Intl.DateTimeFormat('ko-KR', { weekday: 'short' }).format(date)
|
||||
const group = groups.get(key) || { date: key, label: `${format(date, 'M. d')}(${weekday})`, items: [] }
|
||||
group.items.push(tx)
|
||||
groups.set(key, group)
|
||||
}
|
||||
return Array.from(groups.values()).sort((a, b) => b.date.localeCompare(a.date))
|
||||
})
|
||||
|
||||
function moveMonth(amount: number) {
|
||||
const date = parse(selectedMonth.value, 'yyyy-MM', new Date())
|
||||
selectedMonth.value = format(addMonths(date, amount), 'yyyy-MM')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,26 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ShareLink" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"lastViewedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ShareLink_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ShareLink_token_key" ON "ShareLink"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ShareLink_userId_createdAt_idx" ON "ShareLink"("userId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ShareLink_token_expiresAt_revokedAt_idx" ON "ShareLink"("token", "expiresAt", "revokedAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ShareLink" ADD CONSTRAINT "ShareLink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -37,6 +37,7 @@ model User {
|
||||
categories Category[]
|
||||
transactions Transaction[]
|
||||
recurringTransactions RecurringTransaction[]
|
||||
shareLinks ShareLink[]
|
||||
}
|
||||
|
||||
model Asset {
|
||||
@@ -137,3 +138,20 @@ model RecurringTransaction {
|
||||
|
||||
@@index([userId, active, nextRunAt])
|
||||
}
|
||||
|
||||
model ShareLink {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
token String @unique
|
||||
name String
|
||||
expiresAt DateTime
|
||||
revokedAt DateTime?
|
||||
lastViewedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([token, expiresAt, revokedAt])
|
||||
}
|
||||
|
||||
20
server/api/share-links/[id].delete.ts
Normal file
20
server/api/share-links/[id].delete.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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 link = await prisma.shareLink.findFirst({ where: { id, userId: user.id } })
|
||||
|
||||
if (!link) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Share link not found' })
|
||||
}
|
||||
|
||||
await prisma.shareLink.update({
|
||||
where: { id },
|
||||
data: { revokedAt: new Date() }
|
||||
})
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
21
server/api/share-links/index.get.ts
Normal file
21
server/api/share-links/index.get.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { prisma } from '../../utils/db'
|
||||
import { requireUser } from '../../utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event)
|
||||
const links = await prisma.shareLink.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
|
||||
return links.map(link => ({
|
||||
id: link.id,
|
||||
name: link.name,
|
||||
token: link.token,
|
||||
expiresAt: link.expiresAt,
|
||||
revokedAt: link.revokedAt,
|
||||
lastViewedAt: link.lastViewedAt,
|
||||
createdAt: link.createdAt,
|
||||
active: !link.revokedAt && link.expiresAt > new Date()
|
||||
}))
|
||||
})
|
||||
35
server/api/share-links/index.post.ts
Normal file
35
server/api/share-links/index.post.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { addDays } from 'date-fns'
|
||||
import { readBody, createError } from 'h3'
|
||||
import { prisma } from '../../utils/db'
|
||||
import { requireUser } from '../../utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event)
|
||||
const body = await readBody<{ name?: string; days?: number }>(event)
|
||||
const days = Number(body.days || 7)
|
||||
|
||||
if (!Number.isFinite(days) || days < 1 || days > 365) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid expiration days' })
|
||||
}
|
||||
|
||||
const link = await prisma.shareLink.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
token: randomBytes(24).toString('base64url'),
|
||||
name: body.name?.trim().slice(0, 40) || '공유 링크',
|
||||
expiresAt: addDays(new Date(), days)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
id: link.id,
|
||||
name: link.name,
|
||||
token: link.token,
|
||||
expiresAt: link.expiresAt,
|
||||
revokedAt: link.revokedAt,
|
||||
lastViewedAt: link.lastViewedAt,
|
||||
createdAt: link.createdAt,
|
||||
active: true
|
||||
}
|
||||
})
|
||||
58
server/api/shared/[token].get.ts
Normal file
58
server/api/shared/[token].get.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { getQuery, getRouterParam, createError } from 'h3'
|
||||
import { startOfMonth, endOfMonth } from 'date-fns'
|
||||
import { prisma } from '../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = getRouterParam(event, 'token')!
|
||||
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 link = await prisma.shareLink.findUnique({
|
||||
where: { token },
|
||||
include: { user: { select: { id: true, name: true } } }
|
||||
})
|
||||
|
||||
if (!link || link.revokedAt || link.expiresAt <= new Date()) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Share link not found' })
|
||||
}
|
||||
|
||||
const [transactions, assets] = await Promise.all([
|
||||
prisma.transaction.findMany({
|
||||
where: {
|
||||
userId: link.userId,
|
||||
occurredAt: { gte: monthStart, lte: monthEnd }
|
||||
},
|
||||
include: {
|
||||
asset: true,
|
||||
category: { include: { parent: true } }
|
||||
},
|
||||
orderBy: { occurredAt: 'desc' }
|
||||
}),
|
||||
prisma.asset.findMany({
|
||||
where: { userId: link.userId, active: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }]
|
||||
})
|
||||
])
|
||||
|
||||
await prisma.shareLink.update({
|
||||
where: { id: link.id },
|
||||
data: { lastViewedAt: new Date() }
|
||||
})
|
||||
|
||||
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 {
|
||||
owner: link.user.name,
|
||||
name: link.name,
|
||||
expiresAt: link.expiresAt,
|
||||
summary,
|
||||
assets,
|
||||
transactions
|
||||
}
|
||||
})
|
||||
11
todo.md
11
todo.md
@@ -63,7 +63,6 @@
|
||||
- 검색과 필터용 API query를 추가한다.
|
||||
- 카테고리 아이콘/색상 필드를 Prisma schema에 추가한다.
|
||||
- 사용자 설정 테이블을 추가한다.
|
||||
- 읽기 전용 공유 링크 테이블과 만료 기간 필드를 추가한다.
|
||||
- 거래 목록 조회 성능을 고려해 필요한 index를 점검한다.
|
||||
- CSV 내보내기 컬럼을 실제 사용 흐름에 맞게 정리한다.
|
||||
|
||||
@@ -73,18 +72,12 @@
|
||||
- 비밀번호 변경 기능을 구현한다.
|
||||
- 회원 탈퇴 또는 데이터 삭제 정책을 정한다.
|
||||
- 로그인 실패, 세션 만료, 네트워크 오류 상태를 화면에서 더 명확히 보여준다.
|
||||
- 로그아웃 동작과 위치를 정리한다.
|
||||
- 첫 실행 시 기본 자산/카테고리 생성 흐름을 점검한다.
|
||||
- 빈 데이터 상태 화면을 페이지별로 다듬는다.
|
||||
|
||||
## 10. 공유
|
||||
|
||||
- 사용자가 공유 링크를 생성하는 화면을 구현한다.
|
||||
- 공유 링크의 조회 가능 기간을 설정할 수 있게 한다.
|
||||
- 공유 링크 접근자는 읽기 전용으로만 내역, 통계, 자산을 볼 수 있게 한다.
|
||||
- 공유 링크 만료, 삭제, 재생성 기능을 구현한다.
|
||||
- 공유 화면에서 편집, 삭제, 내보내기 같은 쓰기 기능을 숨긴다.
|
||||
- 공유 링크 접근 로그 또는 마지막 조회 시각을 기록할지 결정한다.
|
||||
- 공유 링크 재생성 기능을 구현한다.
|
||||
- 공유 접근 화면의 자산/통계 표시를 더 풍부하게 만든다.
|
||||
|
||||
## 11. 품질
|
||||
|
||||
|
||||
Reference in New Issue
Block a user