Files
nuxt-claude/app/pages/purchases/index.vue
hyeonggil 9ade6abf4c feat: 구매 관리 수량 필드 추가 및 엑셀 업로드 개선
- types/purchase: Purchase·PurchaseInsert에 quantity 필드 추가
- usePurchases: totalSpent·categoryBreakdown를 price×quantity 기준으로 변경
  - extractErrorMessage 헬퍼 추가 (Supabase PostgrestError 메시지 정확히 추출)
- pages/purchases/index: 수량 컬럼 추가, 가격 셀에 합계(단가×수량) 표시
- PurchaseForm: 수량 입력, 세미콜론(;) → ×1000 단가 변환, 합계 미리보기
- PurchaseExcelUpload: 수량 파싱·검증, 단가/수량/합계 컬럼 분리
  - 카테고리 셀 → USelect 인라인 수정 및 즉시 재검증
  - 템플릿에 수량 컬럼 추가 (단가 → 수량 순서)
  - 저장 실패 시 실제 오류 메시지 표시
2026-03-08 23:01:27 +09:00

212 lines
6.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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