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