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"> <script setup lang="ts">
import * as XLSX from 'xlsx' import * as XLSX from 'xlsx'
import type { TableColumn } from '@nuxt/ui' import type { TableColumn } from '@nuxt/ui'
import { CATEGORY_LABELS, CATEGORY_OPTIONS } from '~/types/purchase'
import type { PurchaseInsert, EquipmentCategory } from '~/types/purchase' import type { PurchaseInsert, EquipmentCategory } from '~/types/purchase'
const props = defineProps<{ const props = defineProps<{
@@ -17,6 +18,7 @@ const CATEGORY_MAP: Record<string, EquipmentCategory> = {
'텐트': 'tent', '텐트': 'tent',
'침낭/매트': 'sleeping', '침낭/매트': 'sleeping',
'침낭': 'sleeping', '침낭': 'sleeping',
'매트': 'sleeping',
'취사도구': 'cooking', '취사도구': 'cooking',
'조리': 'cooking', '조리': 'cooking',
'조명': 'lighting', '조명': 'lighting',
@@ -29,10 +31,8 @@ const CATEGORY_MAP: Record<string, EquipmentCategory> = {
'기타': 'other' '기타': 'other'
} }
// 허용 카테고리 문자열 목록 (오류 메시지용)
const VALID_CATEGORIES = Object.keys(CATEGORY_MAP).join(', ')
type RowStatus = 'valid' | 'error' type RowStatus = 'valid' | 'error'
interface PreviewRow { interface PreviewRow {
_index: number _index: number
data: PurchaseInsert data: PurchaseInsert
@@ -40,13 +40,14 @@ interface PreviewRow {
errors: string[] errors: string[]
} }
const { bulkCreatePurchases } = usePurchases() const { bulkCreatePurchases, error: saveError } = usePurchases()
const file = ref<File | null>(null) const file = ref<File | null>(null)
const rows = ref<PreviewRow[]>([]) const rows = ref<PreviewRow[]>([])
const step = ref<'upload' | 'preview'>('upload') const step = ref<'upload' | 'preview'>('upload')
const importing = ref(false) const importing = ref(false)
const isDragging = ref(false) const isDragging = ref(false)
const localError = ref<string | null>(null)
const validRows = computed(() => rows.value.filter(r => r.status === 'valid')) const validRows = computed(() => rows.value.filter(r => r.status === 'valid'))
const errorRows = computed(() => rows.value.filter(r => r.status === 'error')) const errorRows = computed(() => rows.value.filter(r => r.status === 'error'))
@@ -66,7 +67,7 @@ function resetState() {
// Excel 날짜 숫자 → ISO 문자열 변환 // Excel 날짜 숫자 → ISO 문자열 변환
function excelDateToISO(value: unknown): string | null { function excelDateToISO(value: unknown): string | null {
if (value instanceof Date) { if (value instanceof Date) {
return value.toISOString().split('T')[0] ?? null return value.toISOString().slice(0, 10)
} }
if (typeof value === 'number') { if (typeof value === 'number') {
const date = XLSX.SSF.parse_date_code(value) const date = XLSX.SSF.parse_date_code(value)
@@ -76,13 +77,30 @@ function excelDateToISO(value: unknown): string | null {
return `${date.y}-${m}-${d}` return `${date.y}-${m}-${d}`
} }
if (typeof value === 'string' && value.trim()) { if (typeof value === 'string' && value.trim()) {
// YYYY-MM-DD 또는 YYYY/MM/DD 형식 지원
const normalized = value.trim().replace(/\//g, '-') const normalized = value.trim().replace(/\//g, '-')
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) return normalized if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) return normalized
} }
return null 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) { function parseExcel(f: File) {
const reader = new FileReader() const reader = new FileReader()
@@ -91,7 +109,8 @@ function parseExcel(f: File) {
if (!data) return if (!data) return
const wb = XLSX.read(data, { type: 'array', cellDates: true }) 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) { if (!ws) {
rows.value = [] rows.value = []
step.value = 'preview' step.value = 'preview'
@@ -103,26 +122,38 @@ function parseExcel(f: File) {
rows.value = raw.map((row, idx): PreviewRow => { rows.value = raw.map((row, idx): PreviewRow => {
const errors: string[] = [] const errors: string[] = []
// 필수 필드 검증 // 장비명
const name = String(row['장비명'] ?? '').trim() const name = String(row['장비명'] ?? '').trim()
if (!name) errors.push('장비명 필수') if (!name) errors.push('장비명 필수')
// 카테고리
const categoryRaw = String(row['카테고리'] ?? '').trim() const categoryRaw = String(row['카테고리'] ?? '').trim()
const category = CATEGORY_MAP[categoryRaw] const category = CATEGORY_MAP[categoryRaw]
if (!categoryRaw) { if (!categoryRaw) {
errors.push('카테고리 필수') errors.push('카테고리 필수')
} else if (!category) { } 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, '')) const price = typeof priceRaw === 'number' ? priceRaw : Number(String(priceRaw ?? '').replace(/,/g, ''))
if (!priceRaw && priceRaw !== 0) { if (!priceRaw && priceRaw !== 0) {
errors.push('가 필수') errors.push('가 필수')
} else if (isNaN(price) || price < 0) { } 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['구매일']) const purchaseDateISO = excelDateToISO(row['구매일'])
if (!purchaseDateISO) errors.push('구매일 필수 (YYYY-MM-DD 형식)') if (!purchaseDateISO) errors.push('구매일 필수 (YYYY-MM-DD 형식)')
@@ -134,6 +165,7 @@ function parseExcel(f: File) {
category: (category ?? 'other') as EquipmentCategory, category: (category ?? 'other') as EquipmentCategory,
brand: String(row['브랜드'] ?? '').trim() || undefined, brand: String(row['브랜드'] ?? '').trim() || undefined,
price: isNaN(price) ? 0 : price, price: isNaN(price) ? 0 : price,
quantity: isNaN(quantity) || quantity < 1 ? 1 : quantity,
purchase_date: purchaseDateISO ?? '', purchase_date: purchaseDateISO ?? '',
store: String(row['구매처'] ?? '').trim() || undefined, store: String(row['구매처'] ?? '').trim() || undefined,
warranty_until: warrantyISO ?? undefined, warranty_until: warrantyISO ?? undefined,
@@ -174,11 +206,10 @@ function handleDrop(e: DragEvent) {
// 템플릿 다운로드 // 템플릿 다운로드
function downloadTemplate() { function downloadTemplate() {
const headers = ['장비명', '카테고리', '브랜드', '가격', '구매일', '구매처', '보증만료일', '메모'] const headers = ['장비명', '카테고리', '브랜드', '단가', '수량', '구매일', '구매처', '보증만료일', '메모']
const sample = ['코베아 타프', '텐트', '코베아', 120000, '2026-01-15', '캠핑아웃도어', '2027-01-15', ''] const sample = ['코베아 타프', '텐트', '코베아', 120000, 1, '2026-01-15', '캠핑아웃도어', '2027-01-15', '']
const ws = XLSX.utils.aoa_to_sheet([headers, sample])
// 컬럼 너비 설정 const ws = XLSX.utils.aoa_to_sheet([headers, sample])
ws['!cols'] = headers.map(() => ({ wch: 16 })) ws['!cols'] = headers.map(() => ({ wch: 16 }))
const wb = XLSX.utils.book_new() const wb = XLSX.utils.book_new()
@@ -190,11 +221,14 @@ function downloadTemplate() {
async function handleConfirm() { async function handleConfirm() {
if (validRows.value.length === 0) return if (validRows.value.length === 0) return
importing.value = true importing.value = true
localError.value = null
const count = await bulkCreatePurchases(validRows.value.map(r => r.data)) const count = await bulkCreatePurchases(validRows.value.map(r => r.data))
importing.value = false importing.value = false
if (count > 0) { if (count > 0) {
emit('update:open', false) emit('update:open', false)
emit('done') emit('done')
} else {
localError.value = saveError.value ?? '저장에 실패했습니다. 콘솔을 확인해주세요.'
} }
} }
@@ -211,7 +245,9 @@ const previewColumns: TableColumn<PreviewRow>[] = [
{ id: 'status', header: '상태' }, { id: 'status', header: '상태' },
{ id: 'name', header: '장비명' }, { id: 'name', header: '장비명' },
{ id: 'category', header: '카테고리' }, { id: 'category', header: '카테고리' },
{ id: 'price', header: '가격' }, { id: 'quantity', header: '수량' },
{ id: 'price', header: '단가' },
{ id: 'total', header: '합계' },
{ id: 'purchase_date', header: '구매일' }, { id: 'purchase_date', header: '구매일' },
{ id: 'errors', header: '오류' } { id: 'errors', header: '오류' }
] ]
@@ -219,13 +255,18 @@ const previewColumns: TableColumn<PreviewRow>[] = [
function formatPrice(price: number) { function formatPrice(price: number) {
return price.toLocaleString('ko-KR') + '원' return price.toLocaleString('ko-KR') + '원'
} }
// 카테고리 한글 레이블 표시 (영문 key → 한글)
function categoryLabel(cat: EquipmentCategory) {
return CATEGORY_LABELS[cat] ?? cat
}
</script> </script>
<template> <template>
<UModal <UModal
:open="props.open" :open="props.open"
title="엑셀 대량 업로드" title="엑셀 대량 업로드"
:ui="{ content: 'max-w-4xl' }" :ui="{ content: 'max-w-5xl' }"
@update:open="emit('update:open', $event)" @update:open="emit('update:open', $event)"
> >
<template #body> <template #body>
@@ -275,7 +316,7 @@ function formatPrice(price: number) {
color="neutral" color="neutral"
variant="soft" variant="soft"
title="엑셀 파일 형식 안내" title="엑셀 파일 형식 안내"
:description="`필수 컬럼: 장비명, 카테고리, 가, 구매일 / 카테고리 허용값: 텐트, 침낭/매트, 취사도구, 조명, 의류, 배낭, 가구, 안전장비, 전자기기, 기타`" description="필수 컬럼: 장비명, 카테고리, 가, 구매일 / 수량 미입력 시 1로 처리 / 카테고리: 텐트, 침낭/매트, 취사도구, 조명, 의류, 배낭, 가구, 안전장비, 전자기기, 기타"
/> />
</div> </div>
@@ -283,12 +324,8 @@ function formatPrice(price: number) {
<div v-else class="space-y-4"> <div v-else class="space-y-4">
<!-- 요약 뱃지 --> <!-- 요약 뱃지 -->
<div class="flex items-center gap-3 flex-wrap"> <div class="flex items-center gap-3 flex-wrap">
<UBadge color="neutral" variant="soft" size="lg"> <UBadge color="neutral" variant="soft" size="lg"> {{ rows.length }}</UBadge>
{{ rows.length }} <UBadge color="success" variant="soft" size="lg">유효 {{ validRows.length }}</UBadge>
</UBadge>
<UBadge color="success" variant="soft" size="lg">
유효 {{ validRows.length }}
</UBadge>
<UBadge v-if="errorRows.length > 0" color="error" variant="soft" size="lg"> <UBadge v-if="errorRows.length > 0" color="error" variant="soft" size="lg">
오류 {{ errorRows.length }} 오류 {{ errorRows.length }}
</UBadge> </UBadge>
@@ -300,6 +337,7 @@ function formatPrice(price: number) {
<template #row-cell="{ row }"> <template #row-cell="{ row }">
<span class="text-gray-500 text-xs">{{ row.original._index }}</span> <span class="text-gray-500 text-xs">{{ row.original._index }}</span>
</template> </template>
<template #status-cell="{ row }"> <template #status-cell="{ row }">
<UBadge <UBadge
:color="row.original.status === 'valid' ? 'success' : 'error'" :color="row.original.status === 'valid' ? 'success' : 'error'"
@@ -309,18 +347,40 @@ function formatPrice(price: number) {
{{ row.original.status === 'valid' ? '유효' : '오류' }} {{ row.original.status === 'valid' ? '유효' : '오류' }}
</UBadge> </UBadge>
</template> </template>
<template #name-cell="{ row }"> <template #name-cell="{ row }">
<span class="font-medium">{{ row.original.data.name }}</span> <span class="font-medium">{{ row.original.data.name }}</span>
</template> </template>
<!-- 카테고리: 인라인 USelect -->
<template #category-cell="{ row }"> <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>
<template #quantity-cell="{ row }">
<span class="text-center tabular-nums">{{ (row.original.data.quantity ?? 1).toLocaleString() }}</span>
</template>
<template #price-cell="{ row }"> <template #price-cell="{ row }">
{{ formatPrice(row.original.data.price) }} <span class="tabular-nums">{{ formatPrice(row.original.data.price) }}</span>
</template> </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 }"> <template #purchase_date-cell="{ row }">
{{ row.original.data.purchase_date }} {{ row.original.data.purchase_date }}
</template> </template>
<template #errors-cell="{ row }"> <template #errors-cell="{ row }">
<div v-if="row.original.errors.length > 0" class="space-y-1"> <div v-if="row.original.errors.length > 0" class="space-y-1">
<UBadge <UBadge
@@ -329,7 +389,7 @@ function formatPrice(price: number) {
color="error" color="error"
variant="soft" variant="soft"
size="xs" size="xs"
class="block text-left" class="block text-left whitespace-nowrap"
> >
{{ err }} {{ err }}
</UBadge> </UBadge>
@@ -338,25 +398,35 @@ function formatPrice(price: number) {
</UTable> </UTable>
</div> </div>
<!-- 오류 안내 --> <!-- 오류 안내 -->
<UAlert <UAlert
v-if="errorRows.length > 0" v-if="errorRows.length > 0"
color="warning" color="warning"
variant="soft" variant="soft"
icon="i-lucide-triangle-alert" icon="i-lucide-triangle-alert"
:title="`${errorRows.length}개 행에 오류가 있습니다`" :title="`${errorRows.length}개 행에 오류가 있습니다`"
description="오류 행은 저장에서 제외됩니다. 엑셀 파일을 수정 후 다시 업로드하거나, 유효한 행만 저장할 수 있습니다." description="카테고리는 드롭다운에서 직접 수정할 수 있습니다. 다른 오류는 엑셀 파일을 수정 후 업로드하세요."
/>
<!-- 저장 실패 에러 -->
<UAlert
v-if="localError"
color="error"
variant="soft"
icon="i-lucide-alert-circle"
title="저장 실패"
:description="localError"
/> />
</div> </div>
</template> </template>
<template #footer> <template #footer>
<!-- 업로드 단계 푸터 --> <!-- 업로드 단계 -->
<div v-if="step === 'upload'" class="flex justify-end"> <div v-if="step === 'upload'" class="flex justify-end">
<UButton color="neutral" variant="ghost" label="취소" @click="emit('update:open', false)" /> <UButton color="neutral" variant="ghost" label="취소" @click="emit('update:open', false)" />
</div> </div>
<!-- 미리보기 단계 푸터 --> <!-- 미리보기 단계 -->
<div v-else class="flex items-center justify-between w-full"> <div v-else class="flex items-center justify-between w-full">
<UButton <UButton
color="neutral" color="neutral"

View File

@@ -2,7 +2,7 @@
import { z } from 'zod' import { z } from 'zod'
import type { FormSubmitEvent } from '@nuxt/ui' import type { FormSubmitEvent } from '@nuxt/ui'
import { CATEGORY_OPTIONS } from '~/types/purchase' import { CATEGORY_OPTIONS } from '~/types/purchase'
import type { Purchase, PurchaseInsert } from '~/types/purchase' import type { Purchase, PurchaseInsert, EquipmentCategory } from '~/types/purchase'
const props = defineProps<{ const props = defineProps<{
initial?: Purchase initial?: Purchase
@@ -18,6 +18,7 @@ const schema = z.object({
category: z.string().min(1, '카테고리를 선택하세요'), category: z.string().min(1, '카테고리를 선택하세요'),
brand: z.string().optional(), brand: z.string().optional(),
price: z.number().min(0, '가격을 입력하세요'), price: z.number().min(0, '가격을 입력하세요'),
quantity: z.number().int().min(1, '수량은 1 이상이어야 합니다'),
purchase_date: z.string().min(1, '구매일을 입력하세요'), purchase_date: z.string().min(1, '구매일을 입력하세요'),
store: z.string().optional(), store: z.string().optional(),
warranty_until: z.string().optional(), warranty_until: z.string().optional(),
@@ -26,23 +27,72 @@ const schema = z.object({
type Schema = z.output<typeof schema> 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 ?? '', name: props.initial?.name ?? '',
category: props.initial?.category ?? '', category: props.initial?.category,
brand: props.initial?.brand ?? '', brand: props.initial?.brand ?? '',
price: props.initial?.price ?? 0, 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 ?? '', store: props.initial?.store ?? '',
warranty_until: props.initial?.warranty_until ?? '', warranty_until: props.initial?.warranty_until ?? '',
notes: props.initial?.notes ?? '' 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>) { async function onSubmit(event: FormSubmitEvent<Schema>) {
const data: PurchaseInsert = { const data: PurchaseInsert = {
name: event.data.name, name: event.data.name,
category: event.data.category as PurchaseInsert['category'], category: event.data.category as PurchaseInsert['category'],
brand: event.data.brand || undefined, brand: event.data.brand || undefined,
price: event.data.price, price: event.data.price,
quantity: event.data.quantity,
purchase_date: event.data.purchase_date, purchase_date: event.data.purchase_date,
store: event.data.store || undefined, store: event.data.store || undefined,
warranty_until: event.data.warranty_until || undefined, warranty_until: event.data.warranty_until || undefined,
@@ -69,24 +119,48 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<UFormField label="구매가격 (원)" name="price" required> <UFormField label="단가 (원)" name="price" required>
<UInput v-model.number="state.price" type="number" min="0" placeholder="0" class="w-full" /> <UInput
v-model="priceRaw"
inputmode="numeric"
placeholder="0 (숫자 입력 후 ; 로 ×1000)"
class="w-full"
@keydown="handlePriceKeydown"
@input="handlePriceInput"
/>
</UFormField> </UFormField>
<UFormField label="구매일" name="purchase_date" required> <UFormField label="수량" name="quantity" required>
<UInput v-model="state.purchase_date" type="date" class="w-full" /> <UInput v-model.number="state.quantity" type="number" min="1" step="1" placeholder="1" class="w-full" />
</UFormField> </UFormField>
</div> </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"> <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"> <UFormField label="구매처" name="store">
<UInput v-model="state.store" placeholder="예: 아웃도어 월드" class="w-full" /> <UInput v-model="state.store" placeholder="예: 아웃도어 월드" class="w-full" />
</UFormField> </UFormField>
</div>
<UFormField label="보증기간 만료일" name="warranty_until"> <UFormField label="보증기간 만료일" name="warranty_until">
<UInput v-model="state.warranty_until" type="date" class="w-full" /> <UInput v-model="state.warranty_until" type="date" class="w-full" />
</UFormField> </UFormField>
</div>
<UFormField label="메모" name="notes"> <UFormField label="메모" name="notes">
<UTextarea v-model="state.notes" placeholder="추가 메모..." :rows="3" class="w-full" /> <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' 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() { export function usePurchases() {
const client = useSupabaseClient() const client = useSupabaseClient()
const user = useSupabaseUser() const user = useSupabaseUser()
@@ -9,13 +16,13 @@ export function usePurchases() {
const error = ref<string | null>(null) const error = ref<string | null>(null)
const totalSpent = computed(() => 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 categoryBreakdown = computed(() => {
const breakdown: Record<string, number> = {} const breakdown: Record<string, number> = {}
for (const p of purchases.value) { 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 return breakdown
}) })
@@ -33,7 +40,7 @@ export function usePurchases() {
if (err) throw err if (err) throw err
purchases.value = data as Purchase[] purchases.value = data as Purchase[]
} catch (e: unknown) { } catch (e: unknown) {
error.value = e instanceof Error ? e.message : '오류가 발생했습니다' error.value = extractErrorMessage(e, '오류가 발생했습니다')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -53,7 +60,7 @@ export function usePurchases() {
purchases.value.unshift(data as Purchase) purchases.value.unshift(data as Purchase)
return data as Purchase return data as Purchase
} catch (e: unknown) { } catch (e: unknown) {
error.value = e instanceof Error ? e.message : '저장에 실패했습니다' error.value = extractErrorMessage(e, '저장에 실패했습니다')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -76,7 +83,7 @@ export function usePurchases() {
if (idx !== -1) purchases.value[idx] = data as Purchase if (idx !== -1) purchases.value[idx] = data as Purchase
return data as Purchase return data as Purchase
} catch (e: unknown) { } catch (e: unknown) {
error.value = e instanceof Error ? e.message : '수정에 실패했습니다' error.value = extractErrorMessage(e, '수정에 실패했습니다')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -95,7 +102,7 @@ export function usePurchases() {
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) {
error.value = e instanceof Error ? e.message : '삭제에 실패했습니다' error.value = extractErrorMessage(e, '삭제에 실패했습니다')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -128,7 +135,7 @@ export function usePurchases() {
purchases.value.unshift(...(data as Purchase[])) purchases.value.unshift(...(data as Purchase[]))
return data.length return data.length
} catch (e: unknown) { } catch (e: unknown) {
error.value = e instanceof Error ? e.message : '일괄 저장에 실패했습니다' error.value = extractErrorMessage(e, '일괄 저장에 실패했습니다')
return 0 return 0
} finally { } finally {
loading.value = false loading.value = false

View File

@@ -68,6 +68,7 @@ const columns: TableColumn<Purchase>[] = [
{ accessorKey: 'name', header: '장비명' }, { accessorKey: 'name', header: '장비명' },
{ accessorKey: 'category', header: '카테고리' }, { accessorKey: 'category', header: '카테고리' },
{ accessorKey: 'brand', header: '브랜드' }, { accessorKey: 'brand', header: '브랜드' },
{ accessorKey: 'quantity', header: () => h('div', { class: 'text-center' }, '수량') },
{ accessorKey: 'price', header: () => h('div', { class: 'text-right' }, '가격') }, { accessorKey: 'price', header: () => h('div', { class: 'text-right' }, '가격') },
{ accessorKey: 'purchase_date', header: '구매일' }, { accessorKey: 'purchase_date', header: '구매일' },
{ id: 'actions', header: '' } { id: 'actions', header: '' }
@@ -161,8 +162,18 @@ function formatPrice(price: number) {
{{ CATEGORY_LABELS[row.original.category as EquipmentCategory] }} {{ CATEGORY_LABELS[row.original.category as EquipmentCategory] }}
</UBadge> </UBadge>
</template> </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 }"> <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>
<template #actions-cell="{ row }"> <template #actions-cell="{ row }">
<div class="flex gap-1 justify-end"> <div class="flex gap-1 justify-end">

View File

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