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

452 lines
15 KiB
Vue

<script setup lang="ts">
import * as XLSX from 'xlsx'
import type { TableColumn } from '@nuxt/ui'
import { CATEGORY_LABELS, CATEGORY_OPTIONS } from '~/types/purchase'
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',
'매트': 'sleeping',
'취사도구': 'cooking',
'조리': 'cooking',
'조명': 'lighting',
'의류': 'clothing',
'배낭': 'backpack',
'가구': 'furniture',
'가구/의자': 'furniture',
'안전장비': 'safety',
'전자기기': 'electronics',
'기타': 'other'
}
type RowStatus = 'valid' | 'error'
interface PreviewRow {
_index: number
data: PurchaseInsert
status: RowStatus
errors: string[]
}
const { bulkCreatePurchases, error: saveError } = 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 localError = ref<string | null>(null)
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().slice(0, 10)
}
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()) {
const normalized = value.trim().replace(/\//g, '-')
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) return normalized
}
return null
}
// 행 유효성 재검사 (카테고리 인라인 수정 후 호출)
function revalidateRow(row: PreviewRow) {
const errs: string[] = []
if (!row.data.name) errs.push('장비명 필수')
if (!row.data.category) errs.push('카테고리 필수')
if (row.data.price < 0 || isNaN(row.data.price)) errs.push('가격은 0 이상의 숫자여야 합니다')
if (!row.data.purchase_date) errs.push('구매일 필수 (YYYY-MM-DD 형식)')
if ((row.data.quantity ?? 1) < 1) errs.push('수량은 1 이상이어야 합니다')
row.errors = errs
row.status = errs.length === 0 ? 'valid' : 'error'
}
// 카테고리 인라인 변경
function updateCategory(row: PreviewRow, value: EquipmentCategory) {
row.data.category = value
revalidateRow(row)
}
// 엑셀 파일 파싱 및 검증
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 wsName = wb.SheetNames[0]
const ws = wsName ? wb.Sheets[wsName] : undefined
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}"`)
}
// 단가
const priceRaw = row['단가'] ?? 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 이상의 숫자여야 합니다')
}
// 수량 (기본값 1)
const quantityRaw = row['수량']
const quantity = quantityRaw !== '' && quantityRaw !== undefined
? parseInt(String(quantityRaw), 10)
: 1
if (isNaN(quantity) || quantity < 1) {
errors.push('수량은 1 이상의 정수여야 합니다')
}
// 구매일
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,
quantity: isNaN(quantity) || quantity < 1 ? 1 : quantity,
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, 1, '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
localError.value = null
const count = await bulkCreatePurchases(validRows.value.map(r => r.data))
importing.value = false
if (count > 0) {
emit('update:open', false)
emit('done')
} else {
localError.value = saveError.value ?? '저장에 실패했습니다. 콘솔을 확인해주세요.'
}
}
// 뒤로가기
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: 'quantity', header: '수량' },
{ id: 'price', header: '단가' },
{ id: 'total', header: '합계' },
{ id: 'purchase_date', header: '구매일' },
{ id: 'errors', header: '오류' }
]
function formatPrice(price: number) {
return price.toLocaleString('ko-KR') + '원'
}
// 카테고리 한글 레이블 표시 (영문 key → 한글)
function categoryLabel(cat: EquipmentCategory) {
return CATEGORY_LABELS[cat] ?? cat
}
</script>
<template>
<UModal
:open="props.open"
title="엑셀 대량 업로드"
:ui="{ content: 'max-w-5xl' }"
@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="필수 컬럼: 장비명, 카테고리, 단가, 구매일 / 수량 미입력 시 1로 처리 / 카테고리: 텐트, 침낭/매트, 취사도구, 조명, 의류, 배낭, 가구, 안전장비, 전자기기, 기타"
/>
</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>
<!-- 카테고리: 인라인 USelect -->
<template #category-cell="{ row }">
<USelect
:model-value="row.original.data.category"
:items="CATEGORY_OPTIONS"
size="sm"
class="w-32"
@update:model-value="updateCategory(row.original, $event as EquipmentCategory)"
/>
</template>
<template #quantity-cell="{ row }">
<span class="text-center tabular-nums">{{ (row.original.data.quantity ?? 1).toLocaleString() }}</span>
</template>
<template #price-cell="{ row }">
<span class="tabular-nums">{{ formatPrice(row.original.data.price) }}</span>
</template>
<template #total-cell="{ row }">
<span class="font-semibold tabular-nums">
{{ formatPrice(row.original.data.price * (row.original.data.quantity ?? 1)) }}
</span>
</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 whitespace-nowrap"
>
{{ 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="카테고리는 드롭다운에서 직접 수정할 수 있습니다. 다른 오류는 엑셀 파일을 수정 후 재업로드하세요."
/>
<!-- 저장 실패 에러 -->
<UAlert
v-if="localError"
color="error"
variant="soft"
icon="i-lucide-alert-circle"
title="저장 실패"
:description="localError"
/>
</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>