✨ feat: 구매 관리에 엑셀 업로드 및 중고 판매 등록 기능 추가
- usePurchases: user_id 필터링으로 타 사용자 데이터 접근 차단 - usePurchases: bulkCreatePurchases() 일괄 저장 메서드 추가 - PurchaseModal: submit 시 중복 닫힘 방지 (부모에서 제어) - purchases/index: 엑셀 업로드 버튼 및 모달 연동 - purchases/index: 중고 판매 등록(태그 아이콘) 버튼 및 모달 연동 - purchases/index: 판매 상태 뱃지를 장비명 옆에 표시 - PurchaseExcelUpload: xlsx 파일 파싱 후 일괄 저장 컴포넌트 추가 - SellFromPurchaseModal: 구매 장비에서 중고 판매 등록 모달 추가 - xlsx 패키지 추가
This commit is contained in:
381
app/components/purchases/PurchaseExcelUpload.vue
Normal file
381
app/components/purchases/PurchaseExcelUpload.vue
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import * as XLSX from 'xlsx'
|
||||||
|
import type { TableColumn } from '@nuxt/ui'
|
||||||
|
import type { PurchaseInsert, EquipmentCategory } from '~/types/purchase'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:open': [value: boolean]
|
||||||
|
done: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 카테고리 한국어 → 영문 매핑 (별칭 포함)
|
||||||
|
const CATEGORY_MAP: Record<string, EquipmentCategory> = {
|
||||||
|
'텐트': 'tent',
|
||||||
|
'침낭/매트': 'sleeping',
|
||||||
|
'침낭': 'sleeping',
|
||||||
|
'취사도구': 'cooking',
|
||||||
|
'조리': 'cooking',
|
||||||
|
'조명': 'lighting',
|
||||||
|
'의류': 'clothing',
|
||||||
|
'배낭': 'backpack',
|
||||||
|
'가구': 'furniture',
|
||||||
|
'가구/의자': 'furniture',
|
||||||
|
'안전장비': 'safety',
|
||||||
|
'전자기기': 'electronics',
|
||||||
|
'기타': 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 허용 카테고리 문자열 목록 (오류 메시지용)
|
||||||
|
const VALID_CATEGORIES = Object.keys(CATEGORY_MAP).join(', ')
|
||||||
|
|
||||||
|
type RowStatus = 'valid' | 'error'
|
||||||
|
interface PreviewRow {
|
||||||
|
_index: number
|
||||||
|
data: PurchaseInsert
|
||||||
|
status: RowStatus
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bulkCreatePurchases } = 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 validRows = computed(() => rows.value.filter(r => r.status === 'valid'))
|
||||||
|
const errorRows = computed(() => rows.value.filter(r => r.status === 'error'))
|
||||||
|
|
||||||
|
// 모달 닫힐 때 상태 초기화
|
||||||
|
watch(() => props.open, (val) => {
|
||||||
|
if (!val) resetState()
|
||||||
|
})
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
file.value = null
|
||||||
|
rows.value = []
|
||||||
|
step.value = 'upload'
|
||||||
|
importing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Excel 날짜 숫자 → ISO 문자열 변환
|
||||||
|
function excelDateToISO(value: unknown): string | null {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString().split('T')[0] ?? null
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
const date = XLSX.SSF.parse_date_code(value)
|
||||||
|
if (!date) return null
|
||||||
|
const m = String(date.m).padStart(2, '0')
|
||||||
|
const d = String(date.d).padStart(2, '0')
|
||||||
|
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 parseExcel(f: File) {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const data = e.target?.result
|
||||||
|
if (!data) return
|
||||||
|
|
||||||
|
const wb = XLSX.read(data, { type: 'array', cellDates: true })
|
||||||
|
const ws = wb.Sheets[wb.SheetNames[0] ?? '']
|
||||||
|
if (!ws) {
|
||||||
|
rows.value = []
|
||||||
|
step.value = 'preview'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = XLSX.utils.sheet_to_json<Record<string, unknown>>(ws, { defval: '' })
|
||||||
|
|
||||||
|
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})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceRaw = row['가격']
|
||||||
|
const price = typeof priceRaw === 'number' ? priceRaw : Number(String(priceRaw ?? '').replace(/,/g, ''))
|
||||||
|
if (!priceRaw && priceRaw !== 0) {
|
||||||
|
errors.push('가격 필수')
|
||||||
|
} else if (isNaN(price) || price < 0) {
|
||||||
|
errors.push('가격은 0 이상의 숫자여야 합니다')
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchaseDateISO = excelDateToISO(row['구매일'])
|
||||||
|
if (!purchaseDateISO) errors.push('구매일 필수 (YYYY-MM-DD 형식)')
|
||||||
|
|
||||||
|
// 선택 필드
|
||||||
|
const warrantyISO = row['보증만료일'] ? excelDateToISO(row['보증만료일']) : undefined
|
||||||
|
|
||||||
|
const purchaseData: PurchaseInsert = {
|
||||||
|
name,
|
||||||
|
category: (category ?? 'other') as EquipmentCategory,
|
||||||
|
brand: String(row['브랜드'] ?? '').trim() || undefined,
|
||||||
|
price: isNaN(price) ? 0 : price,
|
||||||
|
purchase_date: purchaseDateISO ?? '',
|
||||||
|
store: String(row['구매처'] ?? '').trim() || undefined,
|
||||||
|
warranty_until: warrantyISO ?? undefined,
|
||||||
|
notes: String(row['메모'] ?? '').trim() || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_index: idx + 1,
|
||||||
|
data: purchaseData,
|
||||||
|
status: errors.length === 0 ? 'valid' : 'error',
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
step.value = 'preview'
|
||||||
|
}
|
||||||
|
reader.readAsArrayBuffer(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 선택 처리
|
||||||
|
function handleFileChange(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
const selected = target.files?.[0]
|
||||||
|
if (!selected) return
|
||||||
|
file.value = selected
|
||||||
|
parseExcel(selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 드래그 앤 드롭 처리
|
||||||
|
function handleDrop(e: DragEvent) {
|
||||||
|
isDragging.value = false
|
||||||
|
const dropped = e.dataTransfer?.files?.[0]
|
||||||
|
if (!dropped) return
|
||||||
|
if (!dropped.name.match(/\.(xlsx|xls)$/i)) return
|
||||||
|
file.value = dropped
|
||||||
|
parseExcel(dropped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 템플릿 다운로드
|
||||||
|
function downloadTemplate() {
|
||||||
|
const headers = ['장비명', '카테고리', '브랜드', '가격', '구매일', '구매처', '보증만료일', '메모']
|
||||||
|
const sample = ['코베아 타프', '텐트', '코베아', 120000, '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()
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, '구매내역')
|
||||||
|
XLSX.writeFile(wb, '구매내역_템플릿.xlsx')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 저장 처리
|
||||||
|
async function handleConfirm() {
|
||||||
|
if (validRows.value.length === 0) return
|
||||||
|
importing.value = true
|
||||||
|
const count = await bulkCreatePurchases(validRows.value.map(r => r.data))
|
||||||
|
importing.value = false
|
||||||
|
if (count > 0) {
|
||||||
|
emit('update:open', false)
|
||||||
|
emit('done')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 뒤로가기
|
||||||
|
function handleBack() {
|
||||||
|
step.value = 'upload'
|
||||||
|
rows.value = []
|
||||||
|
file.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블 컬럼 정의
|
||||||
|
const previewColumns: TableColumn<PreviewRow>[] = [
|
||||||
|
{ id: 'row', header: '행' },
|
||||||
|
{ id: 'status', header: '상태' },
|
||||||
|
{ id: 'name', header: '장비명' },
|
||||||
|
{ id: 'category', header: '카테고리' },
|
||||||
|
{ id: 'price', header: '가격' },
|
||||||
|
{ id: 'purchase_date', header: '구매일' },
|
||||||
|
{ id: 'errors', header: '오류' }
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatPrice(price: number) {
|
||||||
|
return price.toLocaleString('ko-KR') + '원'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UModal
|
||||||
|
:open="props.open"
|
||||||
|
title="엑셀 대량 업로드"
|
||||||
|
:ui="{ content: 'max-w-4xl' }"
|
||||||
|
@update:open="emit('update:open', $event)"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<!-- 업로드 단계 -->
|
||||||
|
<div v-if="step === 'upload'" class="space-y-4">
|
||||||
|
<!-- 파일 드롭존 -->
|
||||||
|
<div
|
||||||
|
class="border-2 border-dashed rounded-lg p-10 text-center cursor-pointer transition-colors"
|
||||||
|
:class="isDragging
|
||||||
|
? 'border-primary-500 bg-primary-50 dark:bg-primary-950'
|
||||||
|
: 'border-gray-300 dark:border-gray-600 hover:border-primary-400'"
|
||||||
|
@click="($refs.fileInput as HTMLInputElement).click()"
|
||||||
|
@dragover.prevent="isDragging = true"
|
||||||
|
@dragleave="isDragging = false"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-file-spreadsheet" class="text-5xl text-gray-400 dark:text-gray-500 mb-3 mx-auto" />
|
||||||
|
<p class="text-base font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ file ? file.name : '파일을 클릭하거나 드래그하여 업로드' }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">.xlsx, .xls 파일 지원</p>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFileChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 템플릿 다운로드 -->
|
||||||
|
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<UIcon name="i-lucide-info" class="shrink-0" />
|
||||||
|
<span>처음 사용하시나요?</span>
|
||||||
|
<UButton
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-download"
|
||||||
|
label="템플릿 다운로드"
|
||||||
|
class="p-0"
|
||||||
|
@click.stop="downloadTemplate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 컬럼 안내 -->
|
||||||
|
<UAlert
|
||||||
|
color="neutral"
|
||||||
|
variant="soft"
|
||||||
|
title="엑셀 파일 형식 안내"
|
||||||
|
:description="`필수 컬럼: 장비명, 카테고리, 가격, 구매일 / 카테고리 허용값: 텐트, 침낭/매트, 취사도구, 조명, 의류, 배낭, 가구, 안전장비, 전자기기, 기타`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 미리보기 단계 -->
|
||||||
|
<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 v-if="errorRows.length > 0" color="error" variant="soft" size="lg">
|
||||||
|
오류 {{ errorRows.length }}행
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 미리보기 테이블 -->
|
||||||
|
<div class="max-h-96 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<UTable :data="rows" :columns="previewColumns">
|
||||||
|
<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'"
|
||||||
|
variant="soft"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{{ row.original.status === 'valid' ? '유효' : '오류' }}
|
||||||
|
</UBadge>
|
||||||
|
</template>
|
||||||
|
<template #name-cell="{ row }">
|
||||||
|
<span class="font-medium">{{ row.original.data.name }}</span>
|
||||||
|
</template>
|
||||||
|
<template #category-cell="{ row }">
|
||||||
|
{{ row.original.data.category }}
|
||||||
|
</template>
|
||||||
|
<template #price-cell="{ row }">
|
||||||
|
{{ formatPrice(row.original.data.price) }}
|
||||||
|
</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
|
||||||
|
v-for="(err, i) in row.original.errors"
|
||||||
|
:key="i"
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
size="xs"
|
||||||
|
class="block text-left"
|
||||||
|
>
|
||||||
|
{{ err }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 오류 안내 -->
|
||||||
|
<UAlert
|
||||||
|
v-if="errorRows.length > 0"
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-triangle-alert"
|
||||||
|
:title="`${errorRows.length}개 행에 오류가 있습니다`"
|
||||||
|
description="오류 행은 저장에서 제외됩니다. 엑셀 파일을 수정 후 다시 업로드하거나, 유효한 행만 저장할 수 있습니다."
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
variant="ghost"
|
||||||
|
icon="i-lucide-arrow-left"
|
||||||
|
label="뒤로"
|
||||||
|
@click="handleBack"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<UButton color="neutral" variant="ghost" label="취소" @click="emit('update:open', false)" />
|
||||||
|
<UButton
|
||||||
|
:disabled="validRows.length === 0 || importing"
|
||||||
|
:loading="importing"
|
||||||
|
icon="i-lucide-save"
|
||||||
|
:label="`유효한 ${validRows.length}행 저장`"
|
||||||
|
@click="handleConfirm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
@@ -14,8 +14,8 @@ const emit = defineEmits<{
|
|||||||
const title = computed(() => props.initial ? '장비 수정' : '장비 추가')
|
const title = computed(() => props.initial ? '장비 수정' : '장비 추가')
|
||||||
|
|
||||||
function handleSubmit(data: PurchaseInsert) {
|
function handleSubmit(data: PurchaseInsert) {
|
||||||
|
// 부모의 비동기 핸들러가 완료된 후 모달을 닫으므로 여기서 닫지 않음
|
||||||
emit('submit', data)
|
emit('submit', data)
|
||||||
emit('update:open', false)
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
104
app/components/purchases/SellFromPurchaseModal.vue
Normal file
104
app/components/purchases/SellFromPurchaseModal.vue
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { FormSubmitEvent } from '@nuxt/ui'
|
||||||
|
import { PLATFORM_OPTIONS } from '~/types/used-sale'
|
||||||
|
import type { SalePlatform } from '~/types/used-sale'
|
||||||
|
import type { Purchase } from '~/types/purchase'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
purchase: Purchase | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:open': [value: boolean]
|
||||||
|
done: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { createSale, loading, error: saleError } = useUsedSales()
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
item_name: z.string().min(1, '장비명을 입력하세요'),
|
||||||
|
sale_price: z.number().min(0, '희망가격을 입력하세요'),
|
||||||
|
platform: z.string().min(1, '판매 플랫폼을 선택하세요')
|
||||||
|
})
|
||||||
|
|
||||||
|
type Schema = z.output<typeof schema>
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
item_name: '',
|
||||||
|
sale_price: 0,
|
||||||
|
platform: undefined as SalePlatform | undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
// 선택된 구매 장비로 폼 초기화
|
||||||
|
watch(() => props.purchase, (p) => {
|
||||||
|
if (p) {
|
||||||
|
state.item_name = p.name
|
||||||
|
state.sale_price = p.price
|
||||||
|
state.platform = undefined
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
async function onSubmit(event: FormSubmitEvent<Schema>) {
|
||||||
|
if (!props.purchase) return
|
||||||
|
await createSale({
|
||||||
|
item_name: event.data.item_name,
|
||||||
|
sale_price: event.data.sale_price,
|
||||||
|
platform: event.data.platform as SalePlatform,
|
||||||
|
status: 'listing',
|
||||||
|
listed_at: new Date().toISOString().split('T')[0] ?? '',
|
||||||
|
purchase_id: props.purchase.id
|
||||||
|
})
|
||||||
|
if (!saleError.value) {
|
||||||
|
emit('update:open', false)
|
||||||
|
emit('done')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UModal
|
||||||
|
:open="props.open"
|
||||||
|
title="중고 판매 등록"
|
||||||
|
@update:open="emit('update:open', $event)"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div v-if="purchase" class="space-y-4">
|
||||||
|
<!-- 연결된 구매 장비 안내 -->
|
||||||
|
<div class="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<UIcon name="i-lucide-link" class="shrink-0 text-primary-500" />
|
||||||
|
<span>구매 장비 <strong class="text-gray-900 dark:text-white">{{ purchase.name }}</strong> 와 연결됩니다</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UAlert v-if="saleError" color="error" :description="saleError" />
|
||||||
|
|
||||||
|
<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
|
||||||
|
<UFormField label="장비명" name="item_name" required>
|
||||||
|
<UInput v-model="state.item_name" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<UFormField label="희망가격 (원)" name="sale_price" required>
|
||||||
|
<UInput v-model.number="state.sale_price" type="number" min="0" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="판매 플랫폼" name="platform" required>
|
||||||
|
<USelect v-model="state.platform" :items="PLATFORM_OPTIONS" placeholder="선택..." class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<UButton label="취소" color="neutral" variant="ghost" @click="emit('update:open', false)" />
|
||||||
|
<UButton
|
||||||
|
type="submit"
|
||||||
|
label="중고 등록"
|
||||||
|
icon="i-lucide-tag"
|
||||||
|
:loading="loading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</UForm>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
@@ -28,6 +28,7 @@ export function usePurchases() {
|
|||||||
const { data, error: err } = await client
|
const { data, error: err } = await client
|
||||||
.from('purchases')
|
.from('purchases')
|
||||||
.select('*')
|
.select('*')
|
||||||
|
.eq('user_id', user.value.id)
|
||||||
.order('purchase_date', { ascending: false })
|
.order('purchase_date', { ascending: false })
|
||||||
if (err) throw err
|
if (err) throw err
|
||||||
purchases.value = data as Purchase[]
|
purchases.value = data as Purchase[]
|
||||||
@@ -59,6 +60,7 @@ export function usePurchases() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updatePurchase(id: string, payload: Partial<PurchaseInsert>) {
|
async function updatePurchase(id: string, payload: Partial<PurchaseInsert>) {
|
||||||
|
if (!user.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
@@ -66,6 +68,7 @@ export function usePurchases() {
|
|||||||
.from('purchases')
|
.from('purchases')
|
||||||
.update(payload)
|
.update(payload)
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
|
.eq('user_id', user.value.id)
|
||||||
.select()
|
.select()
|
||||||
.single()
|
.single()
|
||||||
if (err) throw err
|
if (err) throw err
|
||||||
@@ -80,6 +83,7 @@ export function usePurchases() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deletePurchase(id: string) {
|
async function deletePurchase(id: string) {
|
||||||
|
if (!user.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
@@ -87,6 +91,7 @@ export function usePurchases() {
|
|||||||
.from('purchases')
|
.from('purchases')
|
||||||
.delete()
|
.delete()
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
|
.eq('user_id', user.value.id)
|
||||||
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) {
|
||||||
@@ -97,11 +102,13 @@ export function usePurchases() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getPurchase(id: string): Promise<Purchase | null> {
|
async function getPurchase(id: string): Promise<Purchase | null> {
|
||||||
|
if (!user.value) return null
|
||||||
try {
|
try {
|
||||||
const { data, error: err } = await client
|
const { data, error: err } = await client
|
||||||
.from('purchases')
|
.from('purchases')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
|
.eq('user_id', user.value.id)
|
||||||
.single()
|
.single()
|
||||||
if (err) throw err
|
if (err) throw err
|
||||||
return data as Purchase
|
return data as Purchase
|
||||||
@@ -110,6 +117,24 @@ export function usePurchases() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function bulkCreatePurchases(payloads: PurchaseInsert[]): Promise<number> {
|
||||||
|
if (!user.value || payloads.length === 0) return 0
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const rows = payloads.map(p => ({ ...p, user_id: user.value!.id }))
|
||||||
|
const { data, error: err } = await client.from('purchases').insert(rows).select()
|
||||||
|
if (err) throw err
|
||||||
|
purchases.value.unshift(...(data as Purchase[]))
|
||||||
|
return data.length
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : '일괄 저장에 실패했습니다'
|
||||||
|
return 0
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
purchases: readonly(purchases),
|
purchases: readonly(purchases),
|
||||||
loading: readonly(loading),
|
loading: readonly(loading),
|
||||||
@@ -120,6 +145,7 @@ export function usePurchases() {
|
|||||||
createPurchase,
|
createPurchase,
|
||||||
updatePurchase,
|
updatePurchase,
|
||||||
deletePurchase,
|
deletePurchase,
|
||||||
getPurchase
|
getPurchase,
|
||||||
|
bulkCreatePurchases
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,76 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { TableColumn } from '@nuxt/ui'
|
||||||
import { CATEGORY_LABELS, CATEGORY_OPTIONS } from '~/types/purchase'
|
import { CATEGORY_LABELS, CATEGORY_OPTIONS } from '~/types/purchase'
|
||||||
import type { Purchase, PurchaseInsert, EquipmentCategory } from '~/types/purchase'
|
import type { Purchase, PurchaseInsert, EquipmentCategory } from '~/types/purchase'
|
||||||
|
import type { SaleStatus } from '~/types/used-sale'
|
||||||
|
|
||||||
definePageMeta({ middleware: 'auth' })
|
definePageMeta({ middleware: 'auth' })
|
||||||
|
|
||||||
const { purchases, totalSpent, categoryBreakdown, loading, error, fetchPurchases, createPurchase, updatePurchase, deletePurchase } = usePurchases()
|
const { purchases, totalSpent, categoryBreakdown, loading, error, fetchPurchases, createPurchase, updatePurchase, deletePurchase } = usePurchases()
|
||||||
|
const { sales, fetchSales } = useUsedSales()
|
||||||
|
|
||||||
await fetchPurchases()
|
// 페이지가 렌더링된 후 데이터를 가져옴 (UTable loading 상태 활용)
|
||||||
|
onMounted(() => {
|
||||||
|
fetchPurchases()
|
||||||
|
fetchSales()
|
||||||
|
})
|
||||||
|
|
||||||
|
// purchase_id → 가장 활성 상태인 판매 상태 매핑 (listing > reserved > sold > cancelled)
|
||||||
|
const SALE_PRIORITY: Record<SaleStatus, number> = { listing: 4, reserved: 3, sold: 2, cancelled: 1 }
|
||||||
|
|
||||||
|
const saleBadgeMap = computed(() => {
|
||||||
|
const map = new Map<string, SaleStatus>()
|
||||||
|
for (const sale of sales.value) {
|
||||||
|
if (!sale.purchase_id) continue
|
||||||
|
const existing = map.get(sale.purchase_id)
|
||||||
|
if (!existing || SALE_PRIORITY[sale.status] > SALE_PRIORITY[existing]) {
|
||||||
|
map.set(sale.purchase_id, sale.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
|
const showExcelUpload = ref(false)
|
||||||
|
const showSellModal = ref(false)
|
||||||
const editingPurchase = ref<Purchase | undefined>(undefined)
|
const editingPurchase = ref<Purchase | undefined>(undefined)
|
||||||
|
const sellingPurchase = ref<Purchase | null>(null)
|
||||||
|
|
||||||
|
function openSell(purchase: Purchase) {
|
||||||
|
sellingPurchase.value = purchase
|
||||||
|
showSellModal.value = true
|
||||||
|
}
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const selectedCategory = ref('')
|
const selectedCategory = ref('all')
|
||||||
|
|
||||||
const filterOptions = [
|
const filterOptions = [
|
||||||
{ value: '', label: '전체 카테고리' },
|
{ value: 'all', label: '전체 카테고리' },
|
||||||
...CATEGORY_OPTIONS
|
...CATEGORY_OPTIONS
|
||||||
]
|
]
|
||||||
|
|
||||||
const filtered = computed(() => {
|
const filtered = computed<Purchase[]>(() => {
|
||||||
let items = purchases.value
|
let items: Purchase[] = [...purchases.value]
|
||||||
if (searchQuery.value) {
|
if (searchQuery.value) {
|
||||||
const q = searchQuery.value.toLowerCase()
|
const q = searchQuery.value.toLowerCase()
|
||||||
items = items.filter(p =>
|
items = items.filter(p =>
|
||||||
p.name.toLowerCase().includes(q) ||
|
p.name.toLowerCase().includes(q)
|
||||||
p.brand?.toLowerCase().includes(q) ||
|
|| p.brand?.toLowerCase().includes(q)
|
||||||
p.store?.toLowerCase().includes(q)
|
|| p.store?.toLowerCase().includes(q)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (selectedCategory.value) {
|
if (selectedCategory.value !== 'all') {
|
||||||
items = items.filter(p => p.category === selectedCategory.value)
|
items = items.filter(p => p.category === selectedCategory.value)
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
const columns = [
|
const columns: TableColumn<Purchase>[] = [
|
||||||
{ key: 'name', label: '장비명' },
|
{ accessorKey: 'name', header: '장비명' },
|
||||||
{ key: 'category', label: '카테고리' },
|
{ accessorKey: 'category', header: '카테고리' },
|
||||||
{ key: 'brand', label: '브랜드' },
|
{ accessorKey: 'brand', header: '브랜드' },
|
||||||
{ key: 'price', label: '가격' },
|
{ accessorKey: 'price', header: () => h('div', { class: 'text-right' }, '가격') },
|
||||||
{ key: 'purchase_date', label: '구매일' },
|
{ accessorKey: 'purchase_date', header: '구매일' },
|
||||||
{ key: 'actions', label: '' }
|
{ id: 'actions', header: '' }
|
||||||
]
|
]
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
@@ -79,7 +109,10 @@ function formatPrice(price: number) {
|
|||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">구매 관리</h1>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">구매 관리</h1>
|
||||||
<p class="text-gray-500 dark:text-gray-400 mt-1">총 {{ purchases.length }}개 장비 · {{ formatPrice(totalSpent) }}</p>
|
<p class="text-gray-500 dark:text-gray-400 mt-1">총 {{ purchases.length }}개 장비 · {{ formatPrice(totalSpent) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<UButton icon="i-lucide-plus" label="장비 추가" @click="openCreate" />
|
<div class="flex gap-2">
|
||||||
|
<UButton icon="i-lucide-file-spreadsheet" color="neutral" variant="outline" label="엑셀 업로드" @click="showExcelUpload = true" />
|
||||||
|
<UButton icon="i-lucide-plus" label="장비 추가" @click="openCreate" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
@@ -114,19 +147,29 @@ function formatPrice(price: number) {
|
|||||||
:columns="columns"
|
:columns="columns"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
>
|
>
|
||||||
|
<template #name-cell="{ row }">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{{ row.original.name }}</span>
|
||||||
|
<UsedSalesUsedSaleBadge
|
||||||
|
v-if="saleBadgeMap.has(row.original.id)"
|
||||||
|
:status="saleBadgeMap.get(row.original.id)!"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template #category-cell="{ row }">
|
<template #category-cell="{ row }">
|
||||||
<UBadge color="neutral" variant="subtle">
|
<UBadge color="neutral" variant="subtle">
|
||||||
{{ CATEGORY_LABELS[row.original.category as EquipmentCategory] }}
|
{{ CATEGORY_LABELS[row.original.category as EquipmentCategory] }}
|
||||||
</UBadge>
|
</UBadge>
|
||||||
</template>
|
</template>
|
||||||
<template #price-cell="{ row }">
|
<template #price-cell="{ row }">
|
||||||
<span class="font-semibold">{{ formatPrice(row.original.price) }}</span>
|
<div class="text-right font-semibold">{{ formatPrice(row.original.price) }}</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">
|
||||||
<NuxtLink :to="`/purchases/${row.original.id}`">
|
<NuxtLink :to="`/purchases/${row.original.id}`">
|
||||||
<UButton icon="i-lucide-eye" size="sm" color="neutral" variant="ghost" />
|
<UButton icon="i-lucide-eye" size="sm" color="neutral" variant="ghost" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<UButton icon="i-lucide-tag" size="sm" color="warning" variant="ghost" title="중고 판매 등록" @click="openSell(row.original)" />
|
||||||
<UButton icon="i-lucide-pencil" size="sm" color="neutral" variant="ghost" @click="openEdit(row.original)" />
|
<UButton icon="i-lucide-pencil" size="sm" color="neutral" variant="ghost" @click="openEdit(row.original)" />
|
||||||
<UButton icon="i-lucide-trash-2" size="sm" color="error" variant="ghost" @click="handleDelete(row.original.id)" />
|
<UButton icon="i-lucide-trash-2" size="sm" color="error" variant="ghost" @click="handleDelete(row.original.id)" />
|
||||||
</div>
|
</div>
|
||||||
@@ -140,5 +183,18 @@ function formatPrice(price: number) {
|
|||||||
:initial="editingPurchase"
|
:initial="editingPurchase"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 엑셀 업로드 Modal -->
|
||||||
|
<PurchasesPurchaseExcelUpload
|
||||||
|
v-model:open="showExcelUpload"
|
||||||
|
@done="fetchPurchases"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 중고 판매 등록 Modal -->
|
||||||
|
<PurchasesSellFromPurchaseModal
|
||||||
|
v-model:open="showSellModal"
|
||||||
|
:purchase="sellingPurchase"
|
||||||
|
@done="fetchSales"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@nuxtjs/supabase": "^1.5.0",
|
"@nuxtjs/supabase": "^1.5.0",
|
||||||
"nuxt": "^4.3.1",
|
"nuxt": "^4.3.1",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
65
pnpm-lock.yaml
generated
65
pnpm-lock.yaml
generated
@@ -29,6 +29,9 @@ importers:
|
|||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.2.1
|
specifier: ^4.2.1
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
|
xlsx:
|
||||||
|
specifier: ^0.18.5
|
||||||
|
version: 0.18.5
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.24.2
|
specifier: ^3.24.2
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@@ -2317,6 +2320,10 @@ packages:
|
|||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
adler-32@1.3.1:
|
||||||
|
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
agent-base@7.1.4:
|
agent-base@7.1.4:
|
||||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
@@ -2533,6 +2540,10 @@ packages:
|
|||||||
caniuse-lite@1.0.30001770:
|
caniuse-lite@1.0.30001770:
|
||||||
resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==}
|
resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==}
|
||||||
|
|
||||||
|
cfb@1.2.2:
|
||||||
|
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
chalk@5.6.2:
|
chalk@5.6.2:
|
||||||
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
|
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
|
||||||
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
|
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
|
||||||
@@ -2593,6 +2604,10 @@ packages:
|
|||||||
code-block-writer@13.0.3:
|
code-block-writer@13.0.3:
|
||||||
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
|
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
|
||||||
|
|
||||||
|
codepage@1.15.0:
|
||||||
|
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@@ -3345,6 +3360,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
frac@1.1.2:
|
||||||
|
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
fraction.js@5.3.4:
|
fraction.js@5.3.4:
|
||||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||||
|
|
||||||
@@ -5017,6 +5036,10 @@ packages:
|
|||||||
engines: {node: '>=20.16.0'}
|
engines: {node: '>=20.16.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
ssf@0.11.2:
|
||||||
|
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
stable-hash-x@0.2.0:
|
stable-hash-x@0.2.0:
|
||||||
resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==}
|
resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -5644,10 +5667,18 @@ packages:
|
|||||||
engines: {node: ^18.17.0 || >=20.5.0}
|
engines: {node: ^18.17.0 || >=20.5.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
wmf@1.0.2:
|
||||||
|
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
word-wrap@1.2.5:
|
word-wrap@1.2.5:
|
||||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
word@0.3.0:
|
||||||
|
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
wrap-ansi@6.2.0:
|
wrap-ansi@6.2.0:
|
||||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -5683,6 +5714,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
|
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
xlsx@0.18.5:
|
||||||
|
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
xml-name-validator@4.0.0:
|
xml-name-validator@4.0.0:
|
||||||
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
|
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -8221,6 +8257,8 @@ snapshots:
|
|||||||
|
|
||||||
acorn@8.16.0: {}
|
acorn@8.16.0: {}
|
||||||
|
|
||||||
|
adler-32@1.3.1: {}
|
||||||
|
|
||||||
agent-base@7.1.4: {}
|
agent-base@7.1.4: {}
|
||||||
|
|
||||||
agentkeepalive@4.6.0:
|
agentkeepalive@4.6.0:
|
||||||
@@ -8444,6 +8482,11 @@ snapshots:
|
|||||||
|
|
||||||
caniuse-lite@1.0.30001770: {}
|
caniuse-lite@1.0.30001770: {}
|
||||||
|
|
||||||
|
cfb@1.2.2:
|
||||||
|
dependencies:
|
||||||
|
adler-32: 1.3.1
|
||||||
|
crc-32: 1.2.2
|
||||||
|
|
||||||
chalk@5.6.2: {}
|
chalk@5.6.2: {}
|
||||||
|
|
||||||
change-case@5.4.4: {}
|
change-case@5.4.4: {}
|
||||||
@@ -8494,6 +8537,8 @@ snapshots:
|
|||||||
|
|
||||||
code-block-writer@13.0.3: {}
|
code-block-writer@13.0.3: {}
|
||||||
|
|
||||||
|
codepage@1.15.0: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
@@ -9333,6 +9378,8 @@ snapshots:
|
|||||||
|
|
||||||
forwarded@0.2.0: {}
|
forwarded@0.2.0: {}
|
||||||
|
|
||||||
|
frac@1.1.2: {}
|
||||||
|
|
||||||
fraction.js@5.3.4: {}
|
fraction.js@5.3.4: {}
|
||||||
|
|
||||||
framer-motion@12.34.0:
|
framer-motion@12.34.0:
|
||||||
@@ -11248,6 +11295,10 @@ snapshots:
|
|||||||
|
|
||||||
srvx@0.11.4: {}
|
srvx@0.11.4: {}
|
||||||
|
|
||||||
|
ssf@0.11.2:
|
||||||
|
dependencies:
|
||||||
|
frac: 1.1.2
|
||||||
|
|
||||||
stable-hash-x@0.2.0: {}
|
stable-hash-x@0.2.0: {}
|
||||||
|
|
||||||
standard-as-callback@2.1.0: {}
|
standard-as-callback@2.1.0: {}
|
||||||
@@ -11854,8 +11905,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isexe: 3.1.5
|
isexe: 3.1.5
|
||||||
|
|
||||||
|
wmf@1.0.2: {}
|
||||||
|
|
||||||
word-wrap@1.2.5: {}
|
word-wrap@1.2.5: {}
|
||||||
|
|
||||||
|
word@0.3.0: {}
|
||||||
|
|
||||||
wrap-ansi@6.2.0:
|
wrap-ansi@6.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
@@ -11887,6 +11942,16 @@ snapshots:
|
|||||||
is-wsl: 3.1.1
|
is-wsl: 3.1.1
|
||||||
powershell-utils: 0.1.0
|
powershell-utils: 0.1.0
|
||||||
|
|
||||||
|
xlsx@0.18.5:
|
||||||
|
dependencies:
|
||||||
|
adler-32: 1.3.1
|
||||||
|
cfb: 1.2.2
|
||||||
|
codepage: 1.15.0
|
||||||
|
crc-32: 1.2.2
|
||||||
|
ssf: 0.11.2
|
||||||
|
wmf: 1.0.2
|
||||||
|
word: 0.3.0
|
||||||
|
|
||||||
xml-name-validator@4.0.0: {}
|
xml-name-validator@4.0.0: {}
|
||||||
|
|
||||||
y-protocols@1.0.7(yjs@13.6.29):
|
y-protocols@1.0.7(yjs@13.6.29):
|
||||||
|
|||||||
Reference in New Issue
Block a user