feat: nuxt-claude 프로젝트 초기 커밋
Some checks failed
ci / ci (22, ubuntu-latest) (push) Failing after 25m52s
Some checks failed
ci / ci (22, ubuntu-latest) (push) Failing after 25m52s
Made-with: Cursor
This commit is contained in:
193
app/pages/ai-chat/index.vue
Normal file
193
app/pages/ai-chat/index.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ middleware: 'auth' })
|
||||
|
||||
const {
|
||||
conversations,
|
||||
currentConversation,
|
||||
messages,
|
||||
streamingContent,
|
||||
isStreaming,
|
||||
fetchConversations,
|
||||
createConversation,
|
||||
selectConversation,
|
||||
deleteConversation,
|
||||
sendMessage
|
||||
} = useAiChat()
|
||||
|
||||
await fetchConversations()
|
||||
|
||||
const inputText = ref('')
|
||||
const messagesContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
async function handleNewConversation() {
|
||||
const conv = await createConversation()
|
||||
if (conv) await selectConversation(conv)
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
const text = inputText.value.trim()
|
||||
if (!text || isStreaming.value) return
|
||||
if (!currentConversation.value) {
|
||||
const conv = await createConversation()
|
||||
if (!conv) return
|
||||
await selectConversation(conv)
|
||||
}
|
||||
inputText.value = ''
|
||||
await sendMessage(text)
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
// Auto scroll to bottom
|
||||
watch([messages, streamingContent], async () => {
|
||||
await nextTick()
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||
}
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full">
|
||||
<!-- Conversation Sidebar -->
|
||||
<aside class="w-72 border-r border-gray-200 dark:border-gray-800 flex flex-col bg-gray-50 dark:bg-gray-900">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
label="새 대화"
|
||||
class="w-full"
|
||||
@click="handleNewConversation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
<div
|
||||
v-for="conv in conversations"
|
||||
:key="conv.id"
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-2.5 cursor-pointer group transition-colors"
|
||||
:class="currentConversation?.id === conv.id
|
||||
? 'bg-primary-100 dark:bg-primary-950 text-primary-700 dark:text-primary-300'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'"
|
||||
@click="selectConversation(conv)"
|
||||
>
|
||||
<UIcon name="i-lucide-message-square" class="shrink-0 text-sm" />
|
||||
<span class="flex-1 text-sm truncate">{{ conv.title }}</span>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
size="xs"
|
||||
color="error"
|
||||
variant="ghost"
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@click.stop="deleteConversation(conv.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="conversations.length === 0" class="text-center py-8 text-sm text-gray-400">
|
||||
대화가 없습니다
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Chat Area -->
|
||||
<div class="flex-1 flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon name="i-lucide-bot" class="text-xl text-primary-500" />
|
||||
<div>
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ currentConversation?.title ?? 'AI 캠핑 장비 추천' }}
|
||||
</h2>
|
||||
<p class="text-xs text-gray-500">Claude Sonnet으로 구동</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div
|
||||
ref="messagesContainer"
|
||||
class="flex-1 overflow-y-auto p-6 space-y-4"
|
||||
>
|
||||
<!-- Welcome message when no conversation -->
|
||||
<div v-if="!currentConversation" class="flex flex-col items-center justify-center h-full text-center">
|
||||
<UIcon name="i-lucide-tent" class="text-6xl text-gray-300 dark:text-gray-700 mb-4" />
|
||||
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-300 mb-2">AI 캠핑 장비 전문 어시스턴트</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 max-w-md">
|
||||
캠핑 장비 추천, 브랜드 비교, 중고 구매 팁 등 모든 것을 물어보세요!
|
||||
</p>
|
||||
<div class="mt-6 grid grid-cols-2 gap-3 w-full max-w-lg">
|
||||
<UButton
|
||||
v-for="prompt in ['입문자용 텐트 추천해주세요', '4계절 침낭 비교해주세요', '백패킹 장비 리스트 알려주세요', '중고 장비 구매 시 주의사항']"
|
||||
:key="prompt"
|
||||
:label="prompt"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="text-left text-xs"
|
||||
@click="inputText = prompt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<template v-if="currentConversation">
|
||||
<AiChatMessage
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
:role="msg.role"
|
||||
:content="msg.content"
|
||||
/>
|
||||
|
||||
<!-- Streaming message -->
|
||||
<AiChatMessage
|
||||
v-if="isStreaming && streamingContent"
|
||||
role="assistant"
|
||||
:content="streamingContent"
|
||||
:streaming="true"
|
||||
/>
|
||||
|
||||
<!-- Thinking indicator -->
|
||||
<div v-if="isStreaming && !streamingContent" class="flex gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-gray-600 flex items-center justify-center">
|
||||
<UIcon name="i-lucide-bot" class="text-white text-base" />
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-2xl rounded-tl-sm px-4 py-3">
|
||||
<div class="flex gap-1">
|
||||
<span class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0ms" />
|
||||
<span class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 150ms" />
|
||||
<span class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 300ms" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Input Area -->
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
|
||||
<div class="flex gap-3 items-end">
|
||||
<UTextarea
|
||||
v-model="inputText"
|
||||
placeholder="캠핑 장비에 대해 질문하세요... (Shift+Enter: 줄바꿈)"
|
||||
:rows="1"
|
||||
autoresize
|
||||
:max-rows="5"
|
||||
class="flex-1"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-send"
|
||||
:loading="isStreaming"
|
||||
:disabled="!inputText.trim() || isStreaming"
|
||||
@click="handleSend"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-2">Enter로 전송 · Shift+Enter로 줄바꿈</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
26
app/pages/confirm.vue
Normal file
26
app/pages/confirm.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: false })
|
||||
|
||||
const supabase = useSupabaseClient()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await supabase.auth.getSession()
|
||||
if (data.session) {
|
||||
await router.push('/')
|
||||
} else {
|
||||
await router.push('/login')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UApp>
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<div class="text-center space-y-4">
|
||||
<UIcon name="i-lucide-loader-circle" class="text-5xl text-primary-500 animate-spin" />
|
||||
<p class="text-gray-600 dark:text-gray-400">인증 처리 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</UApp>
|
||||
</template>
|
||||
129
app/pages/index.vue
Normal file
129
app/pages/index.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { CATEGORY_LABELS } from '~/types/purchase'
|
||||
import { STATUS_LABELS, PLATFORM_LABELS } from '~/types/used-sale'
|
||||
import type { EquipmentCategory } from '~/types/purchase'
|
||||
import type { SaleStatus, SalePlatform } from '~/types/used-sale'
|
||||
|
||||
definePageMeta({ middleware: 'auth' })
|
||||
|
||||
const { purchases, totalSpent, fetchPurchases } = usePurchases()
|
||||
const { sales, totalRevenue, byStatus, fetchSales } = useUsedSales()
|
||||
|
||||
await Promise.all([fetchPurchases(), fetchSales()])
|
||||
|
||||
const recentPurchases = computed(() => purchases.value.slice(0, 5))
|
||||
const recentSales = computed(() => sales.value.slice(0, 5))
|
||||
|
||||
const stats = computed(() => [
|
||||
{
|
||||
label: '총 구매금액',
|
||||
value: totalSpent.value.toLocaleString('ko-KR') + '원',
|
||||
icon: 'i-lucide-shopping-cart',
|
||||
color: 'text-blue-500'
|
||||
},
|
||||
{
|
||||
label: '보유 장비',
|
||||
value: purchases.value.length + '개',
|
||||
icon: 'i-lucide-tent',
|
||||
color: 'text-green-500'
|
||||
},
|
||||
{
|
||||
label: '판매 수익',
|
||||
value: totalRevenue.value.toLocaleString('ko-KR') + '원',
|
||||
icon: 'i-lucide-coins',
|
||||
color: 'text-yellow-500'
|
||||
},
|
||||
{
|
||||
label: '판매중',
|
||||
value: byStatus.value.listing.length + '개',
|
||||
icon: 'i-lucide-tag',
|
||||
color: 'text-purple-500'
|
||||
}
|
||||
])
|
||||
|
||||
function formatPrice(price: number) {
|
||||
return price.toLocaleString('ko-KR') + '원'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">대시보드</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-1">캠핑 장비 현황을 한눈에 확인하세요</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<UCard v-for="stat in stats" :key="stat.label">
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon :name="stat.icon" class="text-2xl shrink-0" :class="stat.color" />
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ stat.label }}</p>
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ stat.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Recent Purchases -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">최근 구매 장비</h3>
|
||||
<NuxtLink to="/purchases" class="text-sm text-primary-500 hover:text-primary-600">전체보기</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<div
|
||||
v-for="p in recentPurchases"
|
||||
:key="p.id"
|
||||
class="flex items-center justify-between py-3 px-4"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium text-sm text-gray-900 dark:text-white">{{ p.name }}</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ CATEGORY_LABELS[p.category as EquipmentCategory] }} · {{ p.purchase_date }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ formatPrice(p.price) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="recentPurchases.length === 0" class="text-sm text-gray-400 text-center py-6">
|
||||
구매한 장비가 없습니다
|
||||
</p>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Recent Sales -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">중고 판매 현황</h3>
|
||||
<NuxtLink to="/used-sales" class="text-sm text-primary-500 hover:text-primary-600">전체보기</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<div
|
||||
v-for="s in recentSales"
|
||||
:key="s.id"
|
||||
class="flex items-center justify-between py-3 px-4"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium text-sm text-gray-900 dark:text-white">{{ s.item_name }}</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ PLATFORM_LABELS[s.platform as SalePlatform] }} · {{ s.listed_at }}
|
||||
</p>
|
||||
</div>
|
||||
<UsedSalesUsedSaleBadge :status="s.status as SaleStatus" />
|
||||
</div>
|
||||
<p v-if="recentSales.length === 0" class="text-sm text-gray-400 text-center py-6">
|
||||
판매 등록된 장비가 없습니다
|
||||
</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
108
app/pages/login.vue
Normal file
108
app/pages/login.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: false })
|
||||
|
||||
const supabase = useSupabaseClient()
|
||||
const email = ref('')
|
||||
const loading = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
|
||||
async function sendMagicLink() {
|
||||
if (!email.value) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
message.value = ''
|
||||
|
||||
const { error: err } = await supabase.auth.signInWithOtp({
|
||||
email: email.value,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/confirm`
|
||||
}
|
||||
})
|
||||
|
||||
loading.value = false
|
||||
if (err) {
|
||||
error.value = err.message
|
||||
} else {
|
||||
message.value = `${email.value}로 로그인 링크를 전송했습니다. 이메일을 확인해주세요.`
|
||||
}
|
||||
}
|
||||
|
||||
async function signInWithGoogle() {
|
||||
await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/confirm`
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UApp>
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-emerald-100 dark:from-gray-900 dark:to-gray-800 p-4">
|
||||
<UCard class="w-full max-w-md">
|
||||
<!-- Logo -->
|
||||
<template #header>
|
||||
<div class="text-center py-4">
|
||||
<div class="flex items-center justify-center gap-2 mb-2">
|
||||
<UIcon name="i-lucide-tent" class="text-4xl text-primary-500" />
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">CampGear</h1>
|
||||
</div>
|
||||
<p class="text-gray-500 dark:text-gray-400">캠핑 장비 관리 앱</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4 py-2">
|
||||
<!-- Magic Link -->
|
||||
<div class="space-y-3">
|
||||
<UFormField label="이메일" name="email">
|
||||
<UInput
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
class="w-full"
|
||||
@keyup.enter="sendMagicLink"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UButton
|
||||
label="매직 링크로 로그인"
|
||||
icon="i-lucide-mail"
|
||||
class="w-full"
|
||||
:loading="loading"
|
||||
@click="sendMagicLink"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<USeparator label="또는" />
|
||||
|
||||
<!-- Google OAuth -->
|
||||
<UButton
|
||||
label="Google로 로그인"
|
||||
icon="i-simple-icons-google"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="signInWithGoogle"
|
||||
/>
|
||||
|
||||
<!-- Feedback -->
|
||||
<UAlert
|
||||
v-if="message"
|
||||
color="success"
|
||||
icon="i-lucide-check-circle"
|
||||
:description="message"
|
||||
/>
|
||||
<UAlert
|
||||
v-if="error"
|
||||
color="error"
|
||||
icon="i-lucide-alert-circle"
|
||||
:description="error"
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</UApp>
|
||||
</template>
|
||||
103
app/pages/purchases/[id].vue
Normal file
103
app/pages/purchases/[id].vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { CATEGORY_LABELS } from '~/types/purchase'
|
||||
import type { Purchase, PurchaseInsert, EquipmentCategory } from '~/types/purchase'
|
||||
|
||||
definePageMeta({ middleware: 'auth' })
|
||||
|
||||
const route = useRoute()
|
||||
const { getPurchase, updatePurchase, deletePurchase } = usePurchases()
|
||||
|
||||
const purchase = ref<Purchase | null>(null)
|
||||
const showEdit = ref(false)
|
||||
|
||||
purchase.value = await getPurchase(route.params.id as string)
|
||||
|
||||
if (!purchase.value) {
|
||||
await navigateTo('/purchases')
|
||||
}
|
||||
|
||||
async function handleUpdate(data: PurchaseInsert) {
|
||||
if (!purchase.value) return
|
||||
const updated = await updatePurchase(purchase.value.id, data)
|
||||
if (updated) {
|
||||
purchase.value = updated
|
||||
showEdit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!purchase.value || !confirm('이 장비를 삭제하시겠습니까?')) return
|
||||
await deletePurchase(purchase.value.id)
|
||||
await navigateTo('/purchases')
|
||||
}
|
||||
|
||||
function formatPrice(price: number) {
|
||||
return price.toLocaleString('ko-KR') + '원'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="purchase" class="p-6 space-y-6 max-w-2xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4">
|
||||
<NuxtLink to="/purchases">
|
||||
<UButton icon="i-lucide-arrow-left" color="neutral" variant="ghost" />
|
||||
</NuxtLink>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ purchase.name }}</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
{{ CATEGORY_LABELS[purchase.category as EquipmentCategory] }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<UButton icon="i-lucide-pencil" label="수정" color="neutral" variant="outline" @click="showEdit = true" />
|
||||
<UButton icon="i-lucide-trash-2" label="삭제" color="error" variant="outline" @click="handleDelete" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details -->
|
||||
<UCard>
|
||||
<dl class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt class="text-sm text-gray-500 dark:text-gray-400">브랜드</dt>
|
||||
<dd class="mt-1 font-medium text-gray-900 dark:text-white">{{ purchase.brand || '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm text-gray-500 dark:text-gray-400">구매가격</dt>
|
||||
<dd class="mt-1 font-bold text-xl text-primary-600">{{ formatPrice(purchase.price) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm text-gray-500 dark:text-gray-400">구매일</dt>
|
||||
<dd class="mt-1 font-medium text-gray-900 dark:text-white">{{ purchase.purchase_date }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm text-gray-500 dark:text-gray-400">구매처</dt>
|
||||
<dd class="mt-1 font-medium text-gray-900 dark:text-white">{{ purchase.store || '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm text-gray-500 dark:text-gray-400">보증기간 만료</dt>
|
||||
<dd class="mt-1 font-medium text-gray-900 dark:text-white">{{ purchase.warranty_until || '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm text-gray-500 dark:text-gray-400">등록일</dt>
|
||||
<dd class="mt-1 font-medium text-gray-900 dark:text-white">
|
||||
{{ new Date(purchase.created_at).toLocaleDateString('ko-KR') }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<dt class="text-sm text-gray-500 dark:text-gray-400">메모</dt>
|
||||
<dd class="mt-1 font-medium text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{{ purchase.notes || '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</UCard>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<PurchasesPurchaseModal
|
||||
v-model:open="showEdit"
|
||||
:initial="purchase"
|
||||
@submit="handleUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
144
app/pages/purchases/index.vue
Normal file
144
app/pages/purchases/index.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { CATEGORY_LABELS, CATEGORY_OPTIONS } from '~/types/purchase'
|
||||
import type { Purchase, PurchaseInsert, EquipmentCategory } from '~/types/purchase'
|
||||
|
||||
definePageMeta({ middleware: 'auth' })
|
||||
|
||||
const { purchases, totalSpent, categoryBreakdown, loading, error, fetchPurchases, createPurchase, updatePurchase, deletePurchase } = usePurchases()
|
||||
|
||||
await fetchPurchases()
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingPurchase = ref<Purchase | undefined>(undefined)
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref('')
|
||||
|
||||
const filterOptions = [
|
||||
{ value: '', label: '전체 카테고리' },
|
||||
...CATEGORY_OPTIONS
|
||||
]
|
||||
|
||||
const filtered = computed(() => {
|
||||
let items = purchases.value
|
||||
if (searchQuery.value) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
items = items.filter(p =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.brand?.toLowerCase().includes(q) ||
|
||||
p.store?.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
if (selectedCategory.value) {
|
||||
items = items.filter(p => p.category === selectedCategory.value)
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: '장비명' },
|
||||
{ key: 'category', label: '카테고리' },
|
||||
{ key: 'brand', label: '브랜드' },
|
||||
{ key: 'price', label: '가격' },
|
||||
{ key: 'purchase_date', label: '구매일' },
|
||||
{ key: 'actions', label: '' }
|
||||
]
|
||||
|
||||
function openCreate() {
|
||||
editingPurchase.value = undefined
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEdit(purchase: Purchase) {
|
||||
editingPurchase.value = purchase
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit(data: PurchaseInsert) {
|
||||
if (editingPurchase.value) {
|
||||
await updatePurchase(editingPurchase.value.id, data)
|
||||
} else {
|
||||
await createPurchase(data)
|
||||
}
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('이 장비를 삭제하시겠습니까?')) return
|
||||
await deletePurchase(id)
|
||||
}
|
||||
|
||||
function formatPrice(price: number) {
|
||||
return price.toLocaleString('ko-KR') + '원'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">구매 관리</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-1">총 {{ purchases.length }}개 장비 · {{ formatPrice(totalSpent) }}</p>
|
||||
</div>
|
||||
<UButton icon="i-lucide-plus" label="장비 추가" @click="openCreate" />
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<PurchasesPurchaseStats
|
||||
:total-spent="totalSpent"
|
||||
:category-breakdown="categoryBreakdown"
|
||||
:count="purchases.length"
|
||||
/>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex gap-3 flex-wrap">
|
||||
<UInput
|
||||
v-model="searchQuery"
|
||||
icon="i-lucide-search"
|
||||
placeholder="장비명, 브랜드, 구매처 검색..."
|
||||
class="flex-1 min-w-48"
|
||||
/>
|
||||
<USelect
|
||||
v-model="selectedCategory"
|
||||
:items="filterOptions"
|
||||
class="w-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<UAlert v-if="error" color="error" :description="error" />
|
||||
|
||||
<!-- Table -->
|
||||
<UCard>
|
||||
<UTable
|
||||
:data="filtered"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
>
|
||||
<template #category-cell="{ row }">
|
||||
<UBadge color="neutral" variant="subtle">
|
||||
{{ CATEGORY_LABELS[row.original.category as EquipmentCategory] }}
|
||||
</UBadge>
|
||||
</template>
|
||||
<template #price-cell="{ row }">
|
||||
<span class="font-semibold">{{ formatPrice(row.original.price) }}</span>
|
||||
</template>
|
||||
<template #actions-cell="{ row }">
|
||||
<div class="flex gap-1 justify-end">
|
||||
<NuxtLink :to="`/purchases/${row.original.id}`">
|
||||
<UButton icon="i-lucide-eye" size="sm" color="neutral" variant="ghost" />
|
||||
</NuxtLink>
|
||||
<UButton icon="i-lucide-pencil" size="sm" color="neutral" variant="ghost" @click="openEdit(row.original)" />
|
||||
<UButton icon="i-lucide-trash-2" size="sm" color="error" variant="ghost" @click="handleDelete(row.original.id)" />
|
||||
</div>
|
||||
</template>
|
||||
</UTable>
|
||||
</UCard>
|
||||
|
||||
<!-- Modal -->
|
||||
<PurchasesPurchaseModal
|
||||
v-model:open="showModal"
|
||||
:initial="editingPurchase"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
105
app/pages/used-sales/[id].vue
Normal file
105
app/pages/used-sales/[id].vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { STATUS_LABELS, PLATFORM_LABELS } from '~/types/used-sale'
|
||||
import type { UsedSale, UsedSaleInsert, SaleStatus, SalePlatform } from '~/types/used-sale'
|
||||
|
||||
definePageMeta({ middleware: 'auth' })
|
||||
|
||||
const route = useRoute()
|
||||
const { getSale, updateSale, deleteSale } = useUsedSales()
|
||||
|
||||
const sale = ref<UsedSale | null>(null)
|
||||
const showEdit = ref(false)
|
||||
|
||||
sale.value = await getSale(route.params.id as string)
|
||||
|
||||
if (!sale.value) {
|
||||
await navigateTo('/used-sales')
|
||||
}
|
||||
|
||||
async function handleUpdate(data: UsedSaleInsert) {
|
||||
if (!sale.value) return
|
||||
const updated = await updateSale(sale.value.id, data)
|
||||
if (updated) {
|
||||
sale.value = updated
|
||||
showEdit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!sale.value || !confirm('이 판매 등록을 삭제하시겠습니까?')) return
|
||||
await deleteSale(sale.value.id)
|
||||
await navigateTo('/used-sales')
|
||||
}
|
||||
|
||||
function formatPrice(price?: number) {
|
||||
if (!price && price !== 0) return '-'
|
||||
return price.toLocaleString('ko-KR') + '원'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="sale" class="p-6 space-y-6 max-w-2xl">
|
||||
<div class="flex items-center gap-4">
|
||||
<NuxtLink to="/used-sales">
|
||||
<UButton icon="i-lucide-arrow-left" color="neutral" variant="ghost" />
|
||||
</NuxtLink>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ sale.item_name }}</h1>
|
||||
<UsedSalesUsedSaleBadge :status="sale.status as SaleStatus" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<UButton icon="i-lucide-pencil" label="수정" color="neutral" variant="outline" @click="showEdit = true" />
|
||||
<UButton icon="i-lucide-trash-2" label="삭제" color="error" variant="outline" @click="handleDelete" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
<dl class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt class="text-sm text-gray-500 dark:text-gray-400">판매 플랫폼</dt>
|
||||
<dd class="mt-1 font-medium text-gray-900 dark:text-white">
|
||||
{{ PLATFORM_LABELS[sale.platform as SalePlatform] }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm text-gray-500 dark:text-gray-400">판매 상태</dt>
|
||||
<dd class="mt-1">
|
||||
<UsedSalesUsedSaleBadge :status="sale.status as SaleStatus" />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm text-gray-500 dark:text-gray-400">희망가격</dt>
|
||||
<dd class="mt-1 font-bold text-lg text-gray-900 dark:text-white">{{ formatPrice(sale.sale_price) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm text-gray-500 dark:text-gray-400">최종 판매가</dt>
|
||||
<dd class="mt-1 font-bold text-lg text-primary-600">{{ formatPrice(sale.final_price) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm text-gray-500 dark:text-gray-400">등록일</dt>
|
||||
<dd class="mt-1 font-medium text-gray-900 dark:text-white">{{ sale.listed_at }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm text-gray-500 dark:text-gray-400">판매완료일</dt>
|
||||
<dd class="mt-1 font-medium text-gray-900 dark:text-white">{{ sale.sold_at || '-' }}</dd>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<dt class="text-sm text-gray-500 dark:text-gray-400">메모</dt>
|
||||
<dd class="mt-1 font-medium text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{{ sale.notes || '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</UCard>
|
||||
|
||||
<UModal :open="showEdit" title="판매 수정" @update:open="showEdit = $event">
|
||||
<template #body>
|
||||
<UsedSalesUsedSaleForm
|
||||
:initial="sale"
|
||||
@submit="handleUpdate"
|
||||
@cancel="showEdit = false"
|
||||
/>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
160
app/pages/used-sales/index.vue
Normal file
160
app/pages/used-sales/index.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { STATUS_LABELS, PLATFORM_LABELS, STATUS_OPTIONS } from '~/types/used-sale'
|
||||
import type { UsedSale, UsedSaleInsert, SaleStatus, SalePlatform } from '~/types/used-sale'
|
||||
|
||||
definePageMeta({ middleware: 'auth' })
|
||||
|
||||
const { sales, totalRevenue, byStatus, loading, error, fetchSales, createSale, updateSale, deleteSale } = useUsedSales()
|
||||
|
||||
await fetchSales()
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingSale = ref<UsedSale | undefined>(undefined)
|
||||
const activeTab = ref<SaleStatus | 'all'>('all')
|
||||
|
||||
const tabOptions = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'listing', label: `판매중 (${byStatus.value.listing.length})` },
|
||||
{ value: 'reserved', label: `예약중 (${byStatus.value.reserved.length})` },
|
||||
{ value: 'sold', label: `판매완료 (${byStatus.value.sold.length})` },
|
||||
{ value: 'cancelled', label: `취소 (${byStatus.value.cancelled.length})` }
|
||||
]
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (activeTab.value === 'all') return sales.value
|
||||
return sales.value.filter(s => s.status === activeTab.value)
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'item_name', label: '장비명' },
|
||||
{ key: 'platform', label: '플랫폼' },
|
||||
{ key: 'sale_price', label: '희망가' },
|
||||
{ key: 'final_price', label: '최종가' },
|
||||
{ key: 'status', label: '상태' },
|
||||
{ key: 'listed_at', label: '등록일' },
|
||||
{ key: 'actions', label: '' }
|
||||
]
|
||||
|
||||
function openCreate() {
|
||||
editingSale.value = undefined
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEdit(sale: UsedSale) {
|
||||
editingSale.value = sale
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit(data: UsedSaleInsert) {
|
||||
if (editingSale.value) {
|
||||
await updateSale(editingSale.value.id, data)
|
||||
} else {
|
||||
await createSale(data)
|
||||
}
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('이 판매 등록을 삭제하시겠습니까?')) return
|
||||
await deleteSale(id)
|
||||
}
|
||||
|
||||
async function markAsSold(sale: UsedSale) {
|
||||
await updateSale(sale.id, {
|
||||
status: 'sold',
|
||||
final_price: sale.final_price ?? sale.sale_price,
|
||||
sold_at: new Date().toISOString().split('T')[0]
|
||||
})
|
||||
}
|
||||
|
||||
function formatPrice(price?: number) {
|
||||
if (!price && price !== 0) return '-'
|
||||
return price.toLocaleString('ko-KR') + '원'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">중고 판매 관리</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-1">
|
||||
총 판매수익 {{ formatPrice(totalRevenue) }}
|
||||
</p>
|
||||
</div>
|
||||
<UButton icon="i-lucide-plus" label="판매 등록" @click="openCreate" />
|
||||
</div>
|
||||
|
||||
<!-- Status cards -->
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<UCard v-for="opt in tabOptions.slice(1)" :key="opt.value">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-500">{{ STATUS_LABELS[opt.value as SaleStatus] }}</p>
|
||||
<p class="text-2xl font-bold mt-1">{{ byStatus[opt.value as SaleStatus].length }}</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<UButton
|
||||
v-for="opt in tabOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:color="activeTab === opt.value ? 'primary' : 'neutral'"
|
||||
:variant="activeTab === opt.value ? 'solid' : 'ghost'"
|
||||
size="sm"
|
||||
@click="activeTab = opt.value as SaleStatus | 'all'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UAlert v-if="error" color="error" :description="error" />
|
||||
|
||||
<!-- Table -->
|
||||
<UCard>
|
||||
<UTable :data="filtered" :columns="columns" :loading="loading">
|
||||
<template #platform-cell="{ row }">
|
||||
{{ PLATFORM_LABELS[row.original.platform as SalePlatform] }}
|
||||
</template>
|
||||
<template #sale_price-cell="{ row }">
|
||||
{{ formatPrice(row.original.sale_price) }}
|
||||
</template>
|
||||
<template #final_price-cell="{ row }">
|
||||
{{ formatPrice(row.original.final_price) }}
|
||||
</template>
|
||||
<template #status-cell="{ row }">
|
||||
<UsedSalesUsedSaleBadge :status="row.original.status as SaleStatus" />
|
||||
</template>
|
||||
<template #actions-cell="{ row }">
|
||||
<div class="flex gap-1 justify-end">
|
||||
<NuxtLink :to="`/used-sales/${row.original.id}`">
|
||||
<UButton icon="i-lucide-eye" size="sm" color="neutral" variant="ghost" />
|
||||
</NuxtLink>
|
||||
<UButton
|
||||
v-if="row.original.status === 'listing' || row.original.status === 'reserved'"
|
||||
icon="i-lucide-check"
|
||||
size="sm"
|
||||
color="success"
|
||||
variant="ghost"
|
||||
title="판매완료"
|
||||
@click="markAsSold(row.original)"
|
||||
/>
|
||||
<UButton icon="i-lucide-pencil" size="sm" color="neutral" variant="ghost" @click="openEdit(row.original)" />
|
||||
<UButton icon="i-lucide-trash-2" size="sm" color="error" variant="ghost" @click="handleDelete(row.original.id)" />
|
||||
</div>
|
||||
</template>
|
||||
</UTable>
|
||||
</UCard>
|
||||
|
||||
<!-- Modal -->
|
||||
<UModal :open="showModal" :title="editingSale ? '판매 수정' : '판매 등록'" @update:open="showModal = $event">
|
||||
<template #body>
|
||||
<UsedSalesUsedSaleForm
|
||||
:initial="editingSale"
|
||||
@submit="handleSubmit"
|
||||
@cancel="showModal = false"
|
||||
/>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user