- types/purchase: Purchase·PurchaseInsert에 quantity 필드 추가 - usePurchases: totalSpent·categoryBreakdown를 price×quantity 기준으로 변경 - extractErrorMessage 헬퍼 추가 (Supabase PostgrestError 메시지 정확히 추출) - pages/purchases/index: 수량 컬럼 추가, 가격 셀에 합계(단가×수량) 표시 - PurchaseForm: 수량 입력, 세미콜론(;) → ×1000 단가 변환, 합계 미리보기 - PurchaseExcelUpload: 수량 파싱·검증, 단가/수량/합계 컬럼 분리 - 카테고리 셀 → USelect 인라인 수정 및 즉시 재검증 - 템플릿에 수량 컬럼 추가 (단가 → 수량 순서) - 저장 실패 시 실제 오류 메시지 표시
212 lines
6.9 KiB
Vue
212 lines
6.9 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: 'quantity', header: () => h('div', { class: 'text-center' }, '수량') },
|
||
{ 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 #quantity-cell="{ row }">
|
||
<div class="text-center text-gray-600 dark:text-gray-400">
|
||
{{ (row.original.quantity ?? 1).toLocaleString() }}
|
||
</div>
|
||
</template>
|
||
<template #price-cell="{ row }">
|
||
<div class="text-right">
|
||
<div class="font-semibold">{{ formatPrice(row.original.price * (row.original.quantity ?? 1)) }}</div>
|
||
<div v-if="(row.original.quantity ?? 1) > 1" class="text-xs text-gray-400">
|
||
{{ formatPrice(row.original.price) }} × {{ row.original.quantity }}
|
||
</div>
|
||
</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>
|