feat: 구매 관리에 엑셀 업로드 및 중고 판매 등록 기능 추가

- usePurchases: user_id 필터링으로 타 사용자 데이터 접근 차단
- usePurchases: bulkCreatePurchases() 일괄 저장 메서드 추가
- PurchaseModal: submit 시 중복 닫힘 방지 (부모에서 제어)
- purchases/index: 엑셀 업로드 버튼 및 모달 연동
- purchases/index: 중고 판매 등록(태그 아이콘) 버튼 및 모달 연동
- purchases/index: 판매 상태 뱃지를 장비명 옆에 표시
- PurchaseExcelUpload: xlsx 파일 파싱 후 일괄 저장 컴포넌트 추가
- SellFromPurchaseModal: 구매 장비에서 중고 판매 등록 모달 추가
- xlsx 패키지 추가
This commit is contained in:
hyeonggil
2026-03-08 21:25:29 +09:00
parent 9909813c18
commit 6784786262
7 changed files with 653 additions and 20 deletions

View File

@@ -0,0 +1,381 @@
<script setup lang="ts">
import * as XLSX from 'xlsx'
import type { TableColumn } from '@nuxt/ui'
import type { PurchaseInsert, EquipmentCategory } from '~/types/purchase'
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
done: []
}>()
// 카테고리 한국어 → 영문 매핑 (별칭 포함)
const CATEGORY_MAP: Record<string, EquipmentCategory> = {
'텐트': 'tent',
'침낭/매트': 'sleeping',
'침낭': 'sleeping',
'취사도구': 'cooking',
'조리': 'cooking',
'조명': 'lighting',
'의류': 'clothing',
'배낭': 'backpack',
'가구': 'furniture',
'가구/의자': 'furniture',
'안전장비': 'safety',
'전자기기': 'electronics',
'기타': 'other'
}
// 허용 카테고리 문자열 목록 (오류 메시지용)
const VALID_CATEGORIES = Object.keys(CATEGORY_MAP).join(', ')
type RowStatus = 'valid' | 'error'
interface PreviewRow {
_index: number
data: PurchaseInsert
status: RowStatus
errors: string[]
}
const { bulkCreatePurchases } = usePurchases()
const file = ref<File | null>(null)
const rows = ref<PreviewRow[]>([])
const step = ref<'upload' | 'preview'>('upload')
const importing = ref(false)
const isDragging = ref(false)
const validRows = computed(() => rows.value.filter(r => r.status === 'valid'))
const errorRows = computed(() => rows.value.filter(r => r.status === 'error'))
// 모달 닫힐 때 상태 초기화
watch(() => props.open, (val) => {
if (!val) resetState()
})
function resetState() {
file.value = null
rows.value = []
step.value = 'upload'
importing.value = false
}
// Excel 날짜 숫자 → ISO 문자열 변환
function excelDateToISO(value: unknown): string | null {
if (value instanceof Date) {
return value.toISOString().split('T')[0] ?? null
}
if (typeof value === 'number') {
const date = XLSX.SSF.parse_date_code(value)
if (!date) return null
const m = String(date.m).padStart(2, '0')
const d = String(date.d).padStart(2, '0')
return `${date.y}-${m}-${d}`
}
if (typeof value === 'string' && value.trim()) {
// YYYY-MM-DD 또는 YYYY/MM/DD 형식 지원
const normalized = value.trim().replace(/\//g, '-')
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) return normalized
}
return null
}
// 엑셀 파일 파싱 및 검증
function parseExcel(f: File) {
const reader = new FileReader()
reader.onload = (e) => {
const data = e.target?.result
if (!data) return
const wb = XLSX.read(data, { type: 'array', cellDates: true })
const ws = wb.Sheets[wb.SheetNames[0] ?? '']
if (!ws) {
rows.value = []
step.value = 'preview'
return
}
const raw = XLSX.utils.sheet_to_json<Record<string, unknown>>(ws, { defval: '' })
rows.value = raw.map((row, idx): PreviewRow => {
const errors: string[] = []
// 필수 필드 검증
const name = String(row['장비명'] ?? '').trim()
if (!name) errors.push('장비명 필수')
const categoryRaw = String(row['카테고리'] ?? '').trim()
const category = CATEGORY_MAP[categoryRaw]
if (!categoryRaw) {
errors.push('카테고리 필수')
} else if (!category) {
errors.push(`카테고리 오류: "${categoryRaw}" (허용값: ${VALID_CATEGORIES})`)
}
const priceRaw = row['가격']
const price = typeof priceRaw === 'number' ? priceRaw : Number(String(priceRaw ?? '').replace(/,/g, ''))
if (!priceRaw && priceRaw !== 0) {
errors.push('가격 필수')
} else if (isNaN(price) || price < 0) {
errors.push('가격은 0 이상의 숫자여야 합니다')
}
const purchaseDateISO = excelDateToISO(row['구매일'])
if (!purchaseDateISO) errors.push('구매일 필수 (YYYY-MM-DD 형식)')
// 선택 필드
const warrantyISO = row['보증만료일'] ? excelDateToISO(row['보증만료일']) : undefined
const purchaseData: PurchaseInsert = {
name,
category: (category ?? 'other') as EquipmentCategory,
brand: String(row['브랜드'] ?? '').trim() || undefined,
price: isNaN(price) ? 0 : price,
purchase_date: purchaseDateISO ?? '',
store: String(row['구매처'] ?? '').trim() || undefined,
warranty_until: warrantyISO ?? undefined,
notes: String(row['메모'] ?? '').trim() || undefined
}
return {
_index: idx + 1,
data: purchaseData,
status: errors.length === 0 ? 'valid' : 'error',
errors
}
})
step.value = 'preview'
}
reader.readAsArrayBuffer(f)
}
// 파일 선택 처리
function handleFileChange(e: Event) {
const target = e.target as HTMLInputElement
const selected = target.files?.[0]
if (!selected) return
file.value = selected
parseExcel(selected)
}
// 드래그 앤 드롭 처리
function handleDrop(e: DragEvent) {
isDragging.value = false
const dropped = e.dataTransfer?.files?.[0]
if (!dropped) return
if (!dropped.name.match(/\.(xlsx|xls)$/i)) return
file.value = dropped
parseExcel(dropped)
}
// 템플릿 다운로드
function downloadTemplate() {
const headers = ['장비명', '카테고리', '브랜드', '가격', '구매일', '구매처', '보증만료일', '메모']
const sample = ['코베아 타프', '텐트', '코베아', 120000, '2026-01-15', '캠핑아웃도어', '2027-01-15', '']
const ws = XLSX.utils.aoa_to_sheet([headers, sample])
// 컬럼 너비 설정
ws['!cols'] = headers.map(() => ({ wch: 16 }))
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, '구매내역')
XLSX.writeFile(wb, '구매내역_템플릿.xlsx')
}
// 저장 처리
async function handleConfirm() {
if (validRows.value.length === 0) return
importing.value = true
const count = await bulkCreatePurchases(validRows.value.map(r => r.data))
importing.value = false
if (count > 0) {
emit('update:open', false)
emit('done')
}
}
// 뒤로가기
function handleBack() {
step.value = 'upload'
rows.value = []
file.value = null
}
// 테이블 컬럼 정의
const previewColumns: TableColumn<PreviewRow>[] = [
{ id: 'row', header: '행' },
{ id: 'status', header: '상태' },
{ id: 'name', header: '장비명' },
{ id: 'category', header: '카테고리' },
{ id: 'price', header: '가격' },
{ id: 'purchase_date', header: '구매일' },
{ id: 'errors', header: '오류' }
]
function formatPrice(price: number) {
return price.toLocaleString('ko-KR') + '원'
}
</script>
<template>
<UModal
:open="props.open"
title="엑셀 대량 업로드"
:ui="{ content: 'max-w-4xl' }"
@update:open="emit('update:open', $event)"
>
<template #body>
<!-- 업로드 단계 -->
<div v-if="step === 'upload'" class="space-y-4">
<!-- 파일 드롭존 -->
<div
class="border-2 border-dashed rounded-lg p-10 text-center cursor-pointer transition-colors"
:class="isDragging
? 'border-primary-500 bg-primary-50 dark:bg-primary-950'
: 'border-gray-300 dark:border-gray-600 hover:border-primary-400'"
@click="($refs.fileInput as HTMLInputElement).click()"
@dragover.prevent="isDragging = true"
@dragleave="isDragging = false"
@drop.prevent="handleDrop"
>
<UIcon name="i-lucide-file-spreadsheet" class="text-5xl text-gray-400 dark:text-gray-500 mb-3 mx-auto" />
<p class="text-base font-medium text-gray-700 dark:text-gray-300">
{{ file ? file.name : '파일을 클릭하거나 드래그하여 업로드' }}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">.xlsx, .xls 파일 지원</p>
<input
ref="fileInput"
type="file"
accept=".xlsx,.xls"
class="hidden"
@change="handleFileChange"
/>
</div>
<!-- 템플릿 다운로드 -->
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<UIcon name="i-lucide-info" class="shrink-0" />
<span>처음 사용하시나요?</span>
<UButton
variant="link"
size="sm"
icon="i-lucide-download"
label="템플릿 다운로드"
class="p-0"
@click.stop="downloadTemplate"
/>
</div>
<!-- 컬럼 안내 -->
<UAlert
color="neutral"
variant="soft"
title="엑셀 파일 형식 안내"
:description="`필수 컬럼: 장비명, 카테고리, 가격, 구매일 / 카테고리 허용값: 텐트, 침낭/매트, 취사도구, 조명, 의류, 배낭, 가구, 안전장비, 전자기기, 기타`"
/>
</div>
<!-- 미리보기 단계 -->
<div v-else class="space-y-4">
<!-- 요약 뱃지 -->
<div class="flex items-center gap-3 flex-wrap">
<UBadge color="neutral" variant="soft" size="lg">
{{ rows.length }}
</UBadge>
<UBadge color="success" variant="soft" size="lg">
유효 {{ validRows.length }}
</UBadge>
<UBadge v-if="errorRows.length > 0" color="error" variant="soft" size="lg">
오류 {{ errorRows.length }}
</UBadge>
</div>
<!-- 미리보기 테이블 -->
<div class="max-h-96 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700">
<UTable :data="rows" :columns="previewColumns">
<template #row-cell="{ row }">
<span class="text-gray-500 text-xs">{{ row.original._index }}</span>
</template>
<template #status-cell="{ row }">
<UBadge
:color="row.original.status === 'valid' ? 'success' : 'error'"
variant="soft"
size="xs"
>
{{ row.original.status === 'valid' ? '유효' : '오류' }}
</UBadge>
</template>
<template #name-cell="{ row }">
<span class="font-medium">{{ row.original.data.name }}</span>
</template>
<template #category-cell="{ row }">
{{ row.original.data.category }}
</template>
<template #price-cell="{ row }">
{{ formatPrice(row.original.data.price) }}
</template>
<template #purchase_date-cell="{ row }">
{{ row.original.data.purchase_date }}
</template>
<template #errors-cell="{ row }">
<div v-if="row.original.errors.length > 0" class="space-y-1">
<UBadge
v-for="(err, i) in row.original.errors"
:key="i"
color="error"
variant="soft"
size="xs"
class="block text-left"
>
{{ err }}
</UBadge>
</div>
</template>
</UTable>
</div>
<!-- 오류 안내 -->
<UAlert
v-if="errorRows.length > 0"
color="warning"
variant="soft"
icon="i-lucide-triangle-alert"
:title="`${errorRows.length}개 행에 오류가 있습니다`"
description="오류 행은 저장에서 제외됩니다. 엑셀 파일을 수정 후 다시 업로드하거나, 유효한 행만 저장할 수 있습니다."
/>
</div>
</template>
<template #footer>
<!-- 업로드 단계 푸터 -->
<div v-if="step === 'upload'" class="flex justify-end">
<UButton color="neutral" variant="ghost" label="취소" @click="emit('update:open', false)" />
</div>
<!-- 미리보기 단계 푸터 -->
<div v-else class="flex items-center justify-between w-full">
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-arrow-left"
label="뒤로"
@click="handleBack"
/>
<div class="flex gap-2">
<UButton color="neutral" variant="ghost" label="취소" @click="emit('update:open', false)" />
<UButton
:disabled="validRows.length === 0 || importing"
:loading="importing"
icon="i-lucide-save"
:label="`유효한 ${validRows.length}행 저장`"
@click="handleConfirm"
/>
</div>
</div>
</template>
</UModal>
</template>

View File

@@ -14,8 +14,8 @@ const emit = defineEmits<{
const title = computed(() => props.initial ? '장비 수정' : '장비 추가') const title = computed(() => props.initial ? '장비 수정' : '장비 추가')
function handleSubmit(data: PurchaseInsert) { function handleSubmit(data: PurchaseInsert) {
// 부모의 비동기 핸들러가 완료된 후 모달을 닫으므로 여기서 닫지 않음
emit('submit', data) emit('submit', data)
emit('update:open', false)
} }
</script> </script>

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import { z } from 'zod'
import type { FormSubmitEvent } from '@nuxt/ui'
import { PLATFORM_OPTIONS } from '~/types/used-sale'
import type { SalePlatform } from '~/types/used-sale'
import type { Purchase } from '~/types/purchase'
const props = defineProps<{
open: boolean
purchase: Purchase | null
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
done: []
}>()
const { createSale, loading, error: saleError } = useUsedSales()
const schema = z.object({
item_name: z.string().min(1, '장비명을 입력하세요'),
sale_price: z.number().min(0, '희망가격을 입력하세요'),
platform: z.string().min(1, '판매 플랫폼을 선택하세요')
})
type Schema = z.output<typeof schema>
const state = reactive({
item_name: '',
sale_price: 0,
platform: undefined as SalePlatform | undefined
})
// 선택된 구매 장비로 폼 초기화
watch(() => props.purchase, (p) => {
if (p) {
state.item_name = p.name
state.sale_price = p.price
state.platform = undefined
}
}, { immediate: true })
async function onSubmit(event: FormSubmitEvent<Schema>) {
if (!props.purchase) return
await createSale({
item_name: event.data.item_name,
sale_price: event.data.sale_price,
platform: event.data.platform as SalePlatform,
status: 'listing',
listed_at: new Date().toISOString().split('T')[0] ?? '',
purchase_id: props.purchase.id
})
if (!saleError.value) {
emit('update:open', false)
emit('done')
}
}
</script>
<template>
<UModal
:open="props.open"
title="중고 판매 등록"
@update:open="emit('update:open', $event)"
>
<template #body>
<div v-if="purchase" class="space-y-4">
<!-- 연결된 구매 장비 안내 -->
<div class="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-sm text-gray-600 dark:text-gray-400">
<UIcon name="i-lucide-link" class="shrink-0 text-primary-500" />
<span>구매 장비 <strong class="text-gray-900 dark:text-white">{{ purchase.name }}</strong> 연결됩니다</span>
</div>
<UAlert v-if="saleError" color="error" :description="saleError" />
<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<UFormField label="장비명" name="item_name" required>
<UInput v-model="state.item_name" class="w-full" />
</UFormField>
<div class="grid grid-cols-2 gap-4">
<UFormField label="희망가격 (원)" name="sale_price" required>
<UInput v-model.number="state.sale_price" type="number" min="0" class="w-full" />
</UFormField>
<UFormField label="판매 플랫폼" name="platform" required>
<USelect v-model="state.platform" :items="PLATFORM_OPTIONS" placeholder="선택..." class="w-full" />
</UFormField>
</div>
<div class="flex justify-end gap-3 pt-2">
<UButton label="취소" color="neutral" variant="ghost" @click="emit('update:open', false)" />
<UButton
type="submit"
label="중고 등록"
icon="i-lucide-tag"
:loading="loading"
/>
</div>
</UForm>
</div>
</template>
</UModal>
</template>

View File

@@ -28,6 +28,7 @@ export function usePurchases() {
const { data, error: err } = await client const { data, error: err } = await client
.from('purchases') .from('purchases')
.select('*') .select('*')
.eq('user_id', user.value.id)
.order('purchase_date', { ascending: false }) .order('purchase_date', { ascending: false })
if (err) throw err if (err) throw err
purchases.value = data as Purchase[] purchases.value = data as Purchase[]
@@ -59,6 +60,7 @@ export function usePurchases() {
} }
async function updatePurchase(id: string, payload: Partial<PurchaseInsert>) { async function updatePurchase(id: string, payload: Partial<PurchaseInsert>) {
if (!user.value) return
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
@@ -66,6 +68,7 @@ export function usePurchases() {
.from('purchases') .from('purchases')
.update(payload) .update(payload)
.eq('id', id) .eq('id', id)
.eq('user_id', user.value.id)
.select() .select()
.single() .single()
if (err) throw err if (err) throw err
@@ -80,6 +83,7 @@ export function usePurchases() {
} }
async function deletePurchase(id: string) { async function deletePurchase(id: string) {
if (!user.value) return
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
@@ -87,6 +91,7 @@ export function usePurchases() {
.from('purchases') .from('purchases')
.delete() .delete()
.eq('id', id) .eq('id', id)
.eq('user_id', user.value.id)
if (err) throw err if (err) throw err
purchases.value = purchases.value.filter(p => p.id !== id) purchases.value = purchases.value.filter(p => p.id !== id)
} catch (e: unknown) { } catch (e: unknown) {
@@ -97,11 +102,13 @@ export function usePurchases() {
} }
async function getPurchase(id: string): Promise<Purchase | null> { async function getPurchase(id: string): Promise<Purchase | null> {
if (!user.value) return null
try { try {
const { data, error: err } = await client const { data, error: err } = await client
.from('purchases') .from('purchases')
.select('*') .select('*')
.eq('id', id) .eq('id', id)
.eq('user_id', user.value.id)
.single() .single()
if (err) throw err if (err) throw err
return data as Purchase return data as Purchase
@@ -110,6 +117,24 @@ export function usePurchases() {
} }
} }
async function bulkCreatePurchases(payloads: PurchaseInsert[]): Promise<number> {
if (!user.value || payloads.length === 0) return 0
loading.value = true
error.value = null
try {
const rows = payloads.map(p => ({ ...p, user_id: user.value!.id }))
const { data, error: err } = await client.from('purchases').insert(rows).select()
if (err) throw err
purchases.value.unshift(...(data as Purchase[]))
return data.length
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : '일괄 저장에 실패했습니다'
return 0
} finally {
loading.value = false
}
}
return { return {
purchases: readonly(purchases), purchases: readonly(purchases),
loading: readonly(loading), loading: readonly(loading),
@@ -120,6 +145,7 @@ export function usePurchases() {
createPurchase, createPurchase,
updatePurchase, updatePurchase,
deletePurchase, deletePurchase,
getPurchase getPurchase,
bulkCreatePurchases
} }
} }

View File

@@ -1,46 +1,76 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TableColumn } from '@nuxt/ui'
import { CATEGORY_LABELS, CATEGORY_OPTIONS } from '~/types/purchase' import { CATEGORY_LABELS, CATEGORY_OPTIONS } from '~/types/purchase'
import type { Purchase, PurchaseInsert, EquipmentCategory } from '~/types/purchase' import type { Purchase, PurchaseInsert, EquipmentCategory } from '~/types/purchase'
import type { SaleStatus } from '~/types/used-sale'
definePageMeta({ middleware: 'auth' }) definePageMeta({ middleware: 'auth' })
const { purchases, totalSpent, categoryBreakdown, loading, error, fetchPurchases, createPurchase, updatePurchase, deletePurchase } = usePurchases() const { purchases, totalSpent, categoryBreakdown, loading, error, fetchPurchases, createPurchase, updatePurchase, deletePurchase } = usePurchases()
const { sales, fetchSales } = useUsedSales()
await fetchPurchases() // 페이지가 렌더링된 후 데이터를 가져옴 (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 showModal = ref(false)
const showExcelUpload = ref(false)
const showSellModal = ref(false)
const editingPurchase = ref<Purchase | undefined>(undefined) 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 searchQuery = ref('')
const selectedCategory = ref('') const selectedCategory = ref('all')
const filterOptions = [ const filterOptions = [
{ value: '', label: '전체 카테고리' }, { value: 'all', label: '전체 카테고리' },
...CATEGORY_OPTIONS ...CATEGORY_OPTIONS
] ]
const filtered = computed(() => { const filtered = computed<Purchase[]>(() => {
let items = purchases.value let items: Purchase[] = [...purchases.value]
if (searchQuery.value) { if (searchQuery.value) {
const q = searchQuery.value.toLowerCase() const q = searchQuery.value.toLowerCase()
items = items.filter(p => items = items.filter(p =>
p.name.toLowerCase().includes(q) || p.name.toLowerCase().includes(q)
p.brand?.toLowerCase().includes(q) || || p.brand?.toLowerCase().includes(q)
p.store?.toLowerCase().includes(q) || p.store?.toLowerCase().includes(q)
) )
} }
if (selectedCategory.value) { if (selectedCategory.value !== 'all') {
items = items.filter(p => p.category === selectedCategory.value) items = items.filter(p => p.category === selectedCategory.value)
} }
return items return items
}) })
const columns = [ const columns: TableColumn<Purchase>[] = [
{ key: 'name', label: '장비명' }, { accessorKey: 'name', header: '장비명' },
{ key: 'category', label: '카테고리' }, { accessorKey: 'category', header: '카테고리' },
{ key: 'brand', label: '브랜드' }, { accessorKey: 'brand', header: '브랜드' },
{ key: 'price', label: '가격' }, { accessorKey: 'price', header: () => h('div', { class: 'text-right' }, '가격') },
{ key: 'purchase_date', label: '구매일' }, { accessorKey: 'purchase_date', header: '구매일' },
{ key: 'actions', label: '' } { id: 'actions', header: '' }
] ]
function openCreate() { function openCreate() {
@@ -79,7 +109,10 @@ function formatPrice(price: number) {
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">구매 관리</h1> <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> <p class="text-gray-500 dark:text-gray-400 mt-1"> {{ purchases.length }} 장비 · {{ formatPrice(totalSpent) }}</p>
</div> </div>
<UButton icon="i-lucide-plus" label="장비 추가" @click="openCreate" /> <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> </div>
<!-- Stats --> <!-- Stats -->
@@ -114,19 +147,29 @@ function formatPrice(price: number) {
:columns="columns" :columns="columns"
:loading="loading" :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 }"> <template #category-cell="{ row }">
<UBadge color="neutral" variant="subtle"> <UBadge color="neutral" variant="subtle">
{{ CATEGORY_LABELS[row.original.category as EquipmentCategory] }} {{ CATEGORY_LABELS[row.original.category as EquipmentCategory] }}
</UBadge> </UBadge>
</template> </template>
<template #price-cell="{ row }"> <template #price-cell="{ row }">
<span class="font-semibold">{{ formatPrice(row.original.price) }}</span> <div class="text-right font-semibold">{{ formatPrice(row.original.price) }}</div>
</template> </template>
<template #actions-cell="{ row }"> <template #actions-cell="{ row }">
<div class="flex gap-1 justify-end"> <div class="flex gap-1 justify-end">
<NuxtLink :to="`/purchases/${row.original.id}`"> <NuxtLink :to="`/purchases/${row.original.id}`">
<UButton icon="i-lucide-eye" size="sm" color="neutral" variant="ghost" /> <UButton icon="i-lucide-eye" size="sm" color="neutral" variant="ghost" />
</NuxtLink> </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-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)" /> <UButton icon="i-lucide-trash-2" size="sm" color="error" variant="ghost" @click="handleDelete(row.original.id)" />
</div> </div>
@@ -140,5 +183,18 @@ function formatPrice(price: number) {
:initial="editingPurchase" :initial="editingPurchase"
@submit="handleSubmit" @submit="handleSubmit"
/> />
<!-- 엑셀 업로드 Modal -->
<PurchasesPurchaseExcelUpload
v-model:open="showExcelUpload"
@done="fetchPurchases"
/>
<!-- 중고 판매 등록 Modal -->
<PurchasesSellFromPurchaseModal
v-model:open="showSellModal"
:purchase="sellingPurchase"
@done="fetchSales"
/>
</div> </div>
</template> </template>

View File

@@ -18,6 +18,7 @@
"@nuxtjs/supabase": "^1.5.0", "@nuxtjs/supabase": "^1.5.0",
"nuxt": "^4.3.1", "nuxt": "^4.3.1",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"xlsx": "^0.18.5",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {

65
pnpm-lock.yaml generated
View File

@@ -29,6 +29,9 @@ importers:
tailwindcss: tailwindcss:
specifier: ^4.2.1 specifier: ^4.2.1
version: 4.2.1 version: 4.2.1
xlsx:
specifier: ^0.18.5
version: 0.18.5
zod: zod:
specifier: ^3.24.2 specifier: ^3.24.2
version: 3.25.76 version: 3.25.76
@@ -2317,6 +2320,10 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
agent-base@7.1.4: agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
@@ -2533,6 +2540,10 @@ packages:
caniuse-lite@1.0.30001770: caniuse-lite@1.0.30001770:
resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==} resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==}
cfb@1.2.2:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
chalk@5.6.2: chalk@5.6.2:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
@@ -2593,6 +2604,10 @@ packages:
code-block-writer@13.0.3: code-block-writer@13.0.3:
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@@ -3345,6 +3360,10 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
frac@1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
fraction.js@5.3.4: fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
@@ -5017,6 +5036,10 @@ packages:
engines: {node: '>=20.16.0'} engines: {node: '>=20.16.0'}
hasBin: true hasBin: true
ssf@0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
stable-hash-x@0.2.0: stable-hash-x@0.2.0:
resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -5644,10 +5667,18 @@ packages:
engines: {node: ^18.17.0 || >=20.5.0} engines: {node: ^18.17.0 || >=20.5.0}
hasBin: true hasBin: true
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
word-wrap@1.2.5: word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
word@0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
wrap-ansi@6.2.0: wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -5683,6 +5714,11 @@ packages:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'} engines: {node: '>=20'}
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
hasBin: true
xml-name-validator@4.0.0: xml-name-validator@4.0.0:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -8221,6 +8257,8 @@ snapshots:
acorn@8.16.0: {} acorn@8.16.0: {}
adler-32@1.3.1: {}
agent-base@7.1.4: {} agent-base@7.1.4: {}
agentkeepalive@4.6.0: agentkeepalive@4.6.0:
@@ -8444,6 +8482,11 @@ snapshots:
caniuse-lite@1.0.30001770: {} caniuse-lite@1.0.30001770: {}
cfb@1.2.2:
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
chalk@5.6.2: {} chalk@5.6.2: {}
change-case@5.4.4: {} change-case@5.4.4: {}
@@ -8494,6 +8537,8 @@ snapshots:
code-block-writer@13.0.3: {} code-block-writer@13.0.3: {}
codepage@1.15.0: {}
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@@ -9333,6 +9378,8 @@ snapshots:
forwarded@0.2.0: {} forwarded@0.2.0: {}
frac@1.1.2: {}
fraction.js@5.3.4: {} fraction.js@5.3.4: {}
framer-motion@12.34.0: framer-motion@12.34.0:
@@ -11248,6 +11295,10 @@ snapshots:
srvx@0.11.4: {} srvx@0.11.4: {}
ssf@0.11.2:
dependencies:
frac: 1.1.2
stable-hash-x@0.2.0: {} stable-hash-x@0.2.0: {}
standard-as-callback@2.1.0: {} standard-as-callback@2.1.0: {}
@@ -11854,8 +11905,12 @@ snapshots:
dependencies: dependencies:
isexe: 3.1.5 isexe: 3.1.5
wmf@1.0.2: {}
word-wrap@1.2.5: {} word-wrap@1.2.5: {}
word@0.3.0: {}
wrap-ansi@6.2.0: wrap-ansi@6.2.0:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@@ -11887,6 +11942,16 @@ snapshots:
is-wsl: 3.1.1 is-wsl: 3.1.1
powershell-utils: 0.1.0 powershell-utils: 0.1.0
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
xml-name-validator@4.0.0: {} xml-name-validator@4.0.0: {}
y-protocols@1.0.7(yjs@13.6.29): y-protocols@1.0.7(yjs@13.6.29):