✨ 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:
@@ -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"
|
||||
|
||||
@@ -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,25 +119,49 @@ 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>
|
||||
|
||||
<UFormField label="보증기간 만료일" name="warranty_until">
|
||||
<UInput v-model="state.warranty_until" type="date" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<UFormField label="보증기간 만료일" name="warranty_until">
|
||||
<UInput v-model="state.warranty_until" type="date" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="메모" name="notes">
|
||||
<UTextarea v-model="state.notes" placeholder="추가 메모..." :rows="3" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user