feat: nuxt-claude 프로젝트 초기 커밋
Some checks failed
ci / ci (22, ubuntu-latest) (push) Failing after 25m52s

Made-with: Cursor
This commit is contained in:
2026-03-08 16:36:13 +09:00
commit e66321386a
44 changed files with 13058 additions and 0 deletions

193
app/pages/ai-chat/index.vue Normal file
View 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
View 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
View 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
View 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>

View 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>

View 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>

View 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>

View 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>