Files
nuxt-claude/app/pages/purchases/index.vue
hyeonggil 6784786262 feat: 구매 관리에 엑셀 업로드 및 중고 판매 등록 기능 추가
- usePurchases: user_id 필터링으로 타 사용자 데이터 접근 차단
- usePurchases: bulkCreatePurchases() 일괄 저장 메서드 추가
- PurchaseModal: submit 시 중복 닫힘 방지 (부모에서 제어)
- purchases/index: 엑셀 업로드 버튼 및 모달 연동
- purchases/index: 중고 판매 등록(태그 아이콘) 버튼 및 모달 연동
- purchases/index: 판매 상태 뱃지를 장비명 옆에 표시
- PurchaseExcelUpload: xlsx 파일 파싱 후 일괄 저장 컴포넌트 추가
- SellFromPurchaseModal: 구매 장비에서 중고 판매 등록 모달 추가
- xlsx 패키지 추가
2026-03-08 21:25:29 +09:00

201 lines
6.3 KiB
Vue

<script setup lang="ts">
import type { TableColumn } from '@nuxt/ui'
import { CATEGORY_LABELS, CATEGORY_OPTIONS } from '~/types/purchase'
import type { Purchase, PurchaseInsert, EquipmentCategory } from '~/types/purchase'
import type { SaleStatus } from '~/types/used-sale'
definePageMeta({ middleware: 'auth' })
const { purchases, totalSpent, categoryBreakdown, loading, error, fetchPurchases, createPurchase, updatePurchase, deletePurchase } = usePurchases()
const { sales, fetchSales } = useUsedSales()
// 페이지가 렌더링된 후 데이터를 가져옴 (UTable loading 상태 활용)
onMounted(() => {
fetchPurchases()
fetchSales()
})
// purchase_id → 가장 활성 상태인 판매 상태 매핑 (listing > reserved > sold > cancelled)
const SALE_PRIORITY: Record<SaleStatus, number> = { listing: 4, reserved: 3, sold: 2, cancelled: 1 }
const saleBadgeMap = computed(() => {
const map = new Map<string, SaleStatus>()
for (const sale of sales.value) {
if (!sale.purchase_id) continue
const existing = map.get(sale.purchase_id)
if (!existing || SALE_PRIORITY[sale.status] > SALE_PRIORITY[existing]) {
map.set(sale.purchase_id, sale.status)
}
}
return map
})
const showModal = ref(false)
const showExcelUpload = ref(false)
const showSellModal = ref(false)
const editingPurchase = ref<Purchase | undefined>(undefined)
const sellingPurchase = ref<Purchase | null>(null)
function openSell(purchase: Purchase) {
sellingPurchase.value = purchase
showSellModal.value = true
}
const searchQuery = ref('')
const selectedCategory = ref('all')
const filterOptions = [
{ value: 'all', label: '전체 카테고리' },
...CATEGORY_OPTIONS
]
const filtered = computed<Purchase[]>(() => {
let items: Purchase[] = [...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 !== 'all') {
items = items.filter(p => p.category === selectedCategory.value)
}
return items
})
const columns: TableColumn<Purchase>[] = [
{ accessorKey: 'name', header: '장비명' },
{ accessorKey: 'category', header: '카테고리' },
{ accessorKey: 'brand', header: '브랜드' },
{ accessorKey: 'price', header: () => h('div', { class: 'text-right' }, '가격') },
{ accessorKey: 'purchase_date', header: '구매일' },
{ id: 'actions', header: '' }
]
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>
<div class="flex gap-2">
<UButton icon="i-lucide-file-spreadsheet" color="neutral" variant="outline" label="엑셀 업로드" @click="showExcelUpload = true" />
<UButton icon="i-lucide-plus" label="장비 추가" @click="openCreate" />
</div>
</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 #name-cell="{ row }">
<div class="flex items-center gap-2">
<span>{{ row.original.name }}</span>
<UsedSalesUsedSaleBadge
v-if="saleBadgeMap.has(row.original.id)"
:status="saleBadgeMap.get(row.original.id)!"
/>
</div>
</template>
<template #category-cell="{ row }">
<UBadge color="neutral" variant="subtle">
{{ CATEGORY_LABELS[row.original.category as EquipmentCategory] }}
</UBadge>
</template>
<template #price-cell="{ row }">
<div class="text-right font-semibold">{{ formatPrice(row.original.price) }}</div>
</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-tag" size="sm" color="warning" variant="ghost" title="중고 판매 등록" @click="openSell(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 -->
<PurchasesPurchaseModal
v-model:open="showModal"
:initial="editingPurchase"
@submit="handleSubmit"
/>
<!-- 엑셀 업로드 Modal -->
<PurchasesPurchaseExcelUpload
v-model:open="showExcelUpload"
@done="fetchPurchases"
/>
<!-- 중고 판매 등록 Modal -->
<PurchasesSellFromPurchaseModal
v-model:open="showSellModal"
:purchase="sellingPurchase"
@done="fetchSales"
/>
</div>
</template>