- types/purchase: Purchase·PurchaseInsert에 quantity 필드 추가 - usePurchases: totalSpent·categoryBreakdown를 price×quantity 기준으로 변경 - extractErrorMessage 헬퍼 추가 (Supabase PostgrestError 메시지 정확히 추출) - pages/purchases/index: 수량 컬럼 추가, 가격 셀에 합계(단가×수량) 표시 - PurchaseForm: 수량 입력, 세미콜론(;) → ×1000 단가 변환, 합계 미리보기 - PurchaseExcelUpload: 수량 파싱·검증, 단가/수량/합계 컬럼 분리 - 카테고리 셀 → USelect 인라인 수정 및 즉시 재검증 - 템플릿에 수량 컬럼 추가 (단가 → 수량 순서) - 저장 실패 시 실제 오류 메시지 표시
175 lines
5.6 KiB
Vue
175 lines
5.6 KiB
Vue
<script setup lang="ts">
|
||
import { z } from 'zod'
|
||
import type { FormSubmitEvent } from '@nuxt/ui'
|
||
import { CATEGORY_OPTIONS } from '~/types/purchase'
|
||
import type { Purchase, PurchaseInsert, EquipmentCategory } from '~/types/purchase'
|
||
|
||
const props = defineProps<{
|
||
initial?: Purchase
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
submit: [data: PurchaseInsert]
|
||
cancel: []
|
||
}>()
|
||
|
||
const schema = z.object({
|
||
name: z.string().min(1, '장비명을 입력하세요'),
|
||
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(),
|
||
notes: z.string().optional()
|
||
})
|
||
|
||
type Schema = z.output<typeof schema>
|
||
|
||
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,
|
||
brand: props.initial?.brand ?? '',
|
||
price: props.initial?.price ?? 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,
|
||
notes: event.data.notes || undefined
|
||
}
|
||
emit('submit', data)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
|
||
<UFormField label="장비명" name="name" required>
|
||
<UInput v-model="state.name" placeholder="예: MSR Hubba Hubba NX 2" class="w-full" />
|
||
</UFormField>
|
||
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<UFormField label="카테고리" name="category" required>
|
||
<USelect v-model="state.category" :items="CATEGORY_OPTIONS" placeholder="선택..." class="w-full" />
|
||
</UFormField>
|
||
|
||
<UFormField label="브랜드" name="brand">
|
||
<UInput v-model="state.brand" placeholder="예: MSR" class="w-full" />
|
||
</UFormField>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<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="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>
|
||
|
||
<UFormField label="메모" name="notes">
|
||
<UTextarea v-model="state.notes" placeholder="추가 메모..." :rows="3" class="w-full" />
|
||
</UFormField>
|
||
|
||
<div class="flex justify-end gap-3 pt-2">
|
||
<UButton label="취소" color="neutral" variant="ghost" @click="emit('cancel')" />
|
||
<UButton type="submit" :label="initial ? '수정' : '저장'" color="primary" />
|
||
</div>
|
||
</UForm>
|
||
</template>
|