- types/purchase: Purchase·PurchaseInsert에 quantity 필드 추가 - usePurchases: totalSpent·categoryBreakdown를 price×quantity 기준으로 변경 - extractErrorMessage 헬퍼 추가 (Supabase PostgrestError 메시지 정확히 추출) - pages/purchases/index: 수량 컬럼 추가, 가격 셀에 합계(단가×수량) 표시 - PurchaseForm: 수량 입력, 세미콜론(;) → ×1000 단가 변환, 합계 미리보기 - PurchaseExcelUpload: 수량 파싱·검증, 단가/수량/합계 컬럼 분리 - 카테고리 셀 → USelect 인라인 수정 및 즉시 재검증 - 템플릿에 수량 컬럼 추가 (단가 → 수량 순서) - 저장 실패 시 실제 오류 메시지 표시
452 lines
15 KiB
Vue
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>
|