Files
nuxt-claude/app/pages/used-sales/index.vue
hyeonggil f3ebb6002d feat: 중고 판매 장비별 AI 시세 분석 기능 추가
- server/api/ai/market-price.post.ts: 시세 분석 전용 스트리밍 API 추가
  (현재 시세 범위 / 희망가 평가 / 추천 판매가 / 판매 팁)
- UsedSalesMarketPriceAnalysis: 스트리밍 분석 결과 표시 모달 컴포넌트 추가
  (USkeleton 로딩 / whitespace-pre-wrap 텍스트 / 재분석 버튼)
- used-sales/index: sparkles 버튼으로 모달 연동
2026-03-08 21:27:21 +09:00

185 lines
6.0 KiB
Vue

<script setup lang="ts">
import type { TableColumn } from '@nuxt/ui'
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()
onMounted(() => fetchSales())
const showModal = ref(false)
const editingSale = ref<UsedSale | undefined>(undefined)
const activeTab = ref<SaleStatus | 'all'>('all')
const showAnalysis = ref(false)
const analyzingSale = ref<UsedSale | null>(null)
function openAnalysis(sale: UsedSale) {
analyzingSale.value = sale
showAnalysis.value = true
}
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<UsedSale[]>(() => {
const all = [...sales.value]
if (activeTab.value === 'all') return all
return all.filter(s => s.status === activeTab.value)
})
const columns: TableColumn<UsedSale>[] = [
{ accessorKey: 'item_name', header: '장비명' },
{ accessorKey: 'platform', header: '플랫폼' },
{ accessorKey: 'sale_price', header: '희망가' },
{ accessorKey: 'final_price', header: '최종가' },
{ accessorKey: 'status', header: '상태' },
{ accessorKey: 'listed_at', header: '등록일' },
{ id: 'actions', header: '' }
]
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
icon="i-lucide-sparkles"
size="sm"
color="primary"
variant="ghost"
title="AI 시세 분석"
@click="openAnalysis(row.original)"
/>
<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>
<!-- AI 시세 분석 모달 -->
<UsedSalesMarketPriceAnalysis
v-model:open="showAnalysis"
:sale="analyzingSale"
/>
<!-- Modal -->
<UModal :open="showModal" :title="editingSale ? '판매 수정' : '판매 등록'" @update:open="showModal = $event">
<template #body>
<UsedSalesUsedSaleForm
:initial="editingSale"
@submit="handleSubmit"
@cancel="showModal = false"
/>
</template>
</UModal>
</div>
</template>