Files
nuxt-claude/app/components/purchases/PurchaseForm.vue
hyeonggil 9ade6abf4c feat: 구매 관리 수량 필드 추가 및 엑셀 업로드 개선
- types/purchase: Purchase·PurchaseInsert에 quantity 필드 추가
- usePurchases: totalSpent·categoryBreakdown를 price×quantity 기준으로 변경
  - extractErrorMessage 헬퍼 추가 (Supabase PostgrestError 메시지 정확히 추출)
- pages/purchases/index: 수량 컬럼 추가, 가격 셀에 합계(단가×수량) 표시
- PurchaseForm: 수량 입력, 세미콜론(;) → ×1000 단가 변환, 합계 미리보기
- PurchaseExcelUpload: 수량 파싱·검증, 단가/수량/합계 컬럼 분리
  - 카테고리 셀 → USelect 인라인 수정 및 즉시 재검증
  - 템플릿에 수량 컬럼 추가 (단가 → 수량 순서)
  - 저장 실패 시 실제 오류 메시지 표시
2026-03-08 23:01:27 +09:00

175 lines
5.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>