Files
nuxt-claude/app/components/purchases/PurchaseExcelUpload.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

382 lines
12 KiB
Vue

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