feat: 구매 관리 수량 필드 추가 및 엑셀 업로드 개선

- types/purchase: Purchase·PurchaseInsert에 quantity 필드 추가
- usePurchases: totalSpent·categoryBreakdown를 price×quantity 기준으로 변경
  - extractErrorMessage 헬퍼 추가 (Supabase PostgrestError 메시지 정확히 추출)
- pages/purchases/index: 수량 컬럼 추가, 가격 셀에 합계(단가×수량) 표시
- PurchaseForm: 수량 입력, 세미콜론(;) → ×1000 단가 변환, 합계 미리보기
- PurchaseExcelUpload: 수량 파싱·검증, 단가/수량/합계 컬럼 분리
  - 카테고리 셀 → USelect 인라인 수정 및 즉시 재검증
  - 템플릿에 수량 컬럼 추가 (단가 → 수량 순서)
  - 저장 실패 시 실제 오류 메시지 표시
This commit is contained in:
hyeonggil
2026-03-08 23:01:27 +09:00
parent 0a7cba4f93
commit 9ade6abf4c
5 changed files with 216 additions and 52 deletions

View File

@@ -1,6 +1,7 @@
<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<{
@@ -17,6 +18,7 @@ const CATEGORY_MAP: Record<string, EquipmentCategory> = {
'텐트': 'tent',
'침낭/매트': 'sleeping',
'침낭': 'sleeping',
'매트': 'sleeping',
'취사도구': 'cooking',
'조리': 'cooking',
'조명': 'lighting',
@@ -29,10 +31,8 @@ const CATEGORY_MAP: Record<string, EquipmentCategory> = {
'기타': 'other'
}
// 허용 카테고리 문자열 목록 (오류 메시지용)
const VALID_CATEGORIES = Object.keys(CATEGORY_MAP).join(', ')
type RowStatus = 'valid' | 'error'
interface PreviewRow {
_index: number
data: PurchaseInsert
@@ -40,13 +40,14 @@ interface PreviewRow {
errors: string[]
}
const { bulkCreatePurchases } = usePurchases()
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'))
@@ -66,7 +67,7 @@ function resetState() {
// Excel 날짜 숫자 → ISO 문자열 변환
function excelDateToISO(value: unknown): string | null {
if (value instanceof Date) {
return value.toISOString().split('T')[0] ?? null
return value.toISOString().slice(0, 10)
}
if (typeof value === 'number') {
const date = XLSX.SSF.parse_date_code(value)
@@ -76,13 +77,30 @@ function excelDateToISO(value: unknown): string | null {
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 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()
@@ -91,7 +109,8 @@ function parseExcel(f: File) {
if (!data) return
const wb = XLSX.read(data, { type: 'array', cellDates: true })
const ws = wb.Sheets[wb.SheetNames[0] ?? '']
const wsName = wb.SheetNames[0]
const ws = wsName ? wb.Sheets[wsName] : undefined
if (!ws) {
rows.value = []
step.value = 'preview'
@@ -103,26 +122,38 @@ function parseExcel(f: File) {
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})`)
errors.push(`카테고리 오류: "${categoryRaw}"`)
}
const priceRaw = row['가격']
// 단가
const priceRaw = row['단가'] ?? row['가격']
const price = typeof priceRaw === 'number' ? priceRaw : Number(String(priceRaw ?? '').replace(/,/g, ''))
if (!priceRaw && priceRaw !== 0) {
errors.push('가 필수')
errors.push('가 필수')
} else if (isNaN(price) || price < 0) {
errors.push('가격은 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 형식)')
@@ -134,6 +165,7 @@ function parseExcel(f: File) {
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,
@@ -174,11 +206,10 @@ function handleDrop(e: DragEvent) {
// 템플릿 다운로드
function downloadTemplate() {
const headers = ['장비명', '카테고리', '브랜드', '가격', '구매일', '구매처', '보증만료일', '메모']
const sample = ['코베아 타프', '텐트', '코베아', 120000, '2026-01-15', '캠핑아웃도어', '2027-01-15', '']
const ws = XLSX.utils.aoa_to_sheet([headers, sample])
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()
@@ -190,11 +221,14 @@ function downloadTemplate() {
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 ?? '저장에 실패했습니다. 콘솔을 확인해주세요.'
}
}
@@ -211,7 +245,9 @@ const previewColumns: TableColumn<PreviewRow>[] = [
{ id: 'status', header: '상태' },
{ id: 'name', header: '장비명' },
{ id: 'category', header: '카테고리' },
{ id: 'price', header: '가격' },
{ id: 'quantity', header: '수량' },
{ id: 'price', header: '단가' },
{ id: 'total', header: '합계' },
{ id: 'purchase_date', header: '구매일' },
{ id: 'errors', header: '오류' }
]
@@ -219,13 +255,18 @@ const previewColumns: TableColumn<PreviewRow>[] = [
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-4xl' }"
:ui="{ content: 'max-w-5xl' }"
@update:open="emit('update:open', $event)"
>
<template #body>
@@ -275,7 +316,7 @@ function formatPrice(price: number) {
color="neutral"
variant="soft"
title="엑셀 파일 형식 안내"
:description="`필수 컬럼: 장비명, 카테고리, 가, 구매일 / 카테고리 허용값: 텐트, 침낭/매트, 취사도구, 조명, 의류, 배낭, 가구, 안전장비, 전자기기, 기타`"
description="필수 컬럼: 장비명, 카테고리, 가, 구매일 / 수량 미입력 시 1로 처리 / 카테고리: 텐트, 침낭/매트, 취사도구, 조명, 의류, 배낭, 가구, 안전장비, 전자기기, 기타"
/>
</div>
@@ -283,12 +324,8 @@ function formatPrice(price: number) {
<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 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>
@@ -300,6 +337,7 @@ function formatPrice(price: number) {
<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'"
@@ -309,18 +347,40 @@ function formatPrice(price: number) {
{{ 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 }">
{{ row.original.data.category }}
<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 }">
{{ formatPrice(row.original.data.price) }}
<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
@@ -329,7 +389,7 @@ function formatPrice(price: number) {
color="error"
variant="soft"
size="xs"
class="block text-left"
class="block text-left whitespace-nowrap"
>
{{ err }}
</UBadge>
@@ -338,25 +398,35 @@ function formatPrice(price: number) {
</UTable>
</div>
<!-- 오류 안내 -->
<!-- 오류 안내 -->
<UAlert
v-if="errorRows.length > 0"
color="warning"
variant="soft"
icon="i-lucide-triangle-alert"
:title="`${errorRows.length}개 행에 오류가 있습니다`"
description="오류 행은 저장에서 제외됩니다. 엑셀 파일을 수정 후 다시 업로드하거나, 유효한 행만 저장할 수 있습니다."
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"

View File

@@ -2,7 +2,7 @@
import { z } from 'zod'
import type { FormSubmitEvent } from '@nuxt/ui'
import { CATEGORY_OPTIONS } from '~/types/purchase'
import type { Purchase, PurchaseInsert } from '~/types/purchase'
import type { Purchase, PurchaseInsert, EquipmentCategory } from '~/types/purchase'
const props = defineProps<{
initial?: Purchase
@@ -18,6 +18,7 @@ const schema = z.object({
category: z.string().min(1, '카테고리를 선택하세요'),
brand: z.string().optional(),
price: z.number().min(0, '가격을 입력하세요'),
quantity: z.number().int().min(1, '수량은 1 이상이어야 합니다'),
purchase_date: z.string().min(1, '구매일을 입력하세요'),
store: z.string().optional(),
warranty_until: z.string().optional(),
@@ -26,23 +27,72 @@ const schema = z.object({
type Schema = z.output<typeof schema>
const state = reactive({
const state = reactive<{
name: string
category: EquipmentCategory | undefined
brand: string
price: number
quantity: number
purchase_date: string
store: string
warranty_until: string
notes: string
}>({
name: props.initial?.name ?? '',
category: props.initial?.category ?? '',
category: props.initial?.category,
brand: props.initial?.brand ?? '',
price: props.initial?.price ?? 0,
purchase_date: props.initial?.purchase_date ?? new Date().toISOString().split('T')[0],
quantity: props.initial?.quantity ?? 1,
purchase_date: props.initial?.purchase_date ?? new Date().toISOString().slice(0, 10),
store: props.initial?.store ?? '',
warranty_until: props.initial?.warranty_until ?? '',
notes: props.initial?.notes ?? ''
})
// 가격 텍스트 입력 상태 (세미콜론 변환용)
const priceRaw = ref(String(props.initial?.price ?? ''))
// priceRaw 변경 시 숫자만 추출해서 state.price 동기화
watch(priceRaw, (v) => {
const n = parseInt(v.replace(/[^0-9]/g, ''), 10)
state.price = isNaN(n) ? 0 : n
})
// `;` 입력 시 현재 값 × 1000 (예: 50; → 50000)
function handlePriceKeydown(e: KeyboardEvent) {
if (e.key === ';') {
e.preventDefault()
const n = parseInt(priceRaw.value.replace(/[^0-9]/g, ''), 10)
if (!isNaN(n) && n > 0) {
priceRaw.value = String(n * 1000)
state.price = n * 1000
}
}
}
// 숫자 이외 문자 입력 방지
function handlePriceInput(e: Event) {
const input = e.target as HTMLInputElement
const cleaned = input.value.replace(/[^0-9]/g, '')
if (cleaned !== input.value) {
priceRaw.value = cleaned
}
}
// 단가 × 수량 합계
const totalPrice = computed(() => state.price * state.quantity)
function formatPrice(n: number) {
return n.toLocaleString('ko-KR') + '원'
}
async function onSubmit(event: FormSubmitEvent<Schema>) {
const data: PurchaseInsert = {
name: event.data.name,
category: event.data.category as PurchaseInsert['category'],
brand: event.data.brand || undefined,
price: event.data.price,
quantity: event.data.quantity,
purchase_date: event.data.purchase_date,
store: event.data.store || undefined,
warranty_until: event.data.warranty_until || undefined,
@@ -69,24 +119,48 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
</div>
<div class="grid grid-cols-2 gap-4">
<UFormField label="구매가격 (원)" name="price" required>
<UInput v-model.number="state.price" type="number" min="0" placeholder="0" class="w-full" />
<UFormField label="단가 (원)" name="price" required>
<UInput
v-model="priceRaw"
inputmode="numeric"
placeholder="0 (숫자 입력 후 ; 로 ×1000)"
class="w-full"
@keydown="handlePriceKeydown"
@input="handlePriceInput"
/>
</UFormField>
<UFormField label="구매일" name="purchase_date" required>
<UInput v-model="state.purchase_date" type="date" class="w-full" />
<UFormField label="수량" name="quantity" required>
<UInput v-model.number="state.quantity" type="number" min="1" step="1" placeholder="1" class="w-full" />
</UFormField>
</div>
<!-- 합계 미리보기 -->
<div
v-if="state.price > 0 && state.quantity > 1"
class="flex items-center justify-between rounded-lg bg-primary-50 dark:bg-primary-950/30 px-4 py-2 text-sm"
>
<span class="text-gray-600 dark:text-gray-400">
{{ formatPrice(state.price) }} × {{ state.quantity }}
</span>
<span class="font-bold text-primary-600 dark:text-primary-400">
합계 {{ formatPrice(totalPrice) }}
</span>
</div>
<div class="grid grid-cols-2 gap-4">
<UFormField label="구매일" name="purchase_date" required>
<UInput v-model="state.purchase_date" type="date" class="w-full" />
</UFormField>
<UFormField label="구매처" name="store">
<UInput v-model="state.store" placeholder="예: 아웃도어 월드" class="w-full" />
</UFormField>
</div>
<UFormField label="보증기간 만료일" name="warranty_until">
<UInput v-model="state.warranty_until" type="date" class="w-full" />
</UFormField>
</div>
<UFormField label="메모" name="notes">
<UTextarea v-model="state.notes" placeholder="추가 메모..." :rows="3" class="w-full" />

View File

@@ -1,5 +1,12 @@
import type { Purchase, PurchaseInsert, EquipmentCategory } from '~/types/purchase'
// Supabase PostgrestError는 instanceof Error가 아니므로 message 프로퍼티를 직접 추출
function extractErrorMessage(e: unknown, fallback: string): string {
if (e instanceof Error) return e.message
if (e && typeof e === 'object' && 'message' in e) return String((e as { message: unknown }).message)
return fallback
}
export function usePurchases() {
const client = useSupabaseClient()
const user = useSupabaseUser()
@@ -9,13 +16,13 @@ export function usePurchases() {
const error = ref<string | null>(null)
const totalSpent = computed(() =>
purchases.value.reduce((sum, p) => sum + p.price, 0)
purchases.value.reduce((sum, p) => sum + p.price * (p.quantity ?? 1), 0)
)
const categoryBreakdown = computed(() => {
const breakdown: Record<string, number> = {}
for (const p of purchases.value) {
breakdown[p.category] = (breakdown[p.category] ?? 0) + p.price
breakdown[p.category] = (breakdown[p.category] ?? 0) + p.price * (p.quantity ?? 1)
}
return breakdown
})
@@ -33,7 +40,7 @@ export function usePurchases() {
if (err) throw err
purchases.value = data as Purchase[]
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : '오류가 발생했습니다'
error.value = extractErrorMessage(e, '오류가 발생했습니다')
} finally {
loading.value = false
}
@@ -53,7 +60,7 @@ export function usePurchases() {
purchases.value.unshift(data as Purchase)
return data as Purchase
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : '저장에 실패했습니다'
error.value = extractErrorMessage(e, '저장에 실패했습니다')
} finally {
loading.value = false
}
@@ -76,7 +83,7 @@ export function usePurchases() {
if (idx !== -1) purchases.value[idx] = data as Purchase
return data as Purchase
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : '수정에 실패했습니다'
error.value = extractErrorMessage(e, '수정에 실패했습니다')
} finally {
loading.value = false
}
@@ -95,7 +102,7 @@ export function usePurchases() {
if (err) throw err
purchases.value = purchases.value.filter(p => p.id !== id)
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : '삭제에 실패했습니다'
error.value = extractErrorMessage(e, '삭제에 실패했습니다')
} finally {
loading.value = false
}
@@ -128,7 +135,7 @@ export function usePurchases() {
purchases.value.unshift(...(data as Purchase[]))
return data.length
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : '일괄 저장에 실패했습니다'
error.value = extractErrorMessage(e, '일괄 저장에 실패했습니다')
return 0
} finally {
loading.value = false

View File

@@ -68,6 +68,7 @@ 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: '' }
@@ -161,8 +162,18 @@ function formatPrice(price: number) {
{{ 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 font-semibold">{{ formatPrice(row.original.price) }}</div>
<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">

View File

@@ -35,6 +35,7 @@ export interface Purchase {
category: EquipmentCategory
brand?: string
price: number
quantity: number
purchase_date: string
store?: string
warranty_until?: string
@@ -48,6 +49,7 @@ export interface PurchaseInsert {
category: EquipmentCategory
brand?: string
price: number
quantity: number
purchase_date: string
store?: string
warranty_until?: string