Files
KArtSell.Aegis/docs/Design/kbx-foundation-v36/packages/kbx-ui/src/components/KbxExcelImport.vue
T

208 lines
15 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type {
KbxImportDefinition,
KbxImportMapping,
KbxImportSession,
KbxImportProgressEvent,
} from '@kbx/contracts'
import KbxButton from './KbxButton.vue'
import KbxConfirm from './KbxConfirm.vue'
import KbxJobProgress from './KbxJobProgress.vue'
import KbxProgressSteps from './KbxProgressSteps.vue'
import { validateKbxImportFileCandidate, validateKbxImportMappings } from '../excel/importGuard'
const props = defineProps<{
definition: KbxImportDefinition
session: KbxImportSession | null
busy?: boolean
progress?: KbxImportProgressEvent | null
}>()
const emit = defineEmits<{
upload: [File]
saveMapping: [KbxImportMapping[]]
saveNamedMapping: [string, KbxImportMapping[]]
validate: []
commit: []
downloadTemplate: []
downloadErrors: []
cancel: []
}>()
const fileInput = ref<HTMLInputElement | null>(null)
const localMapping = ref<KbxImportMapping[] | null>(null)
const dragActive = ref(false)
const mappingName = ref('')
const clientIssue = ref('')
const commitConfirmOpen = ref(false)
const importSteps=[{key:'file',label:'파일'},{key:'mapping',label:'매핑'},{key:'validation',label:'검증'},{key:'commit',label:'반영'}]
const currentStepKey=computed(()=>step.value===1?'file':step.value===2?'mapping':step.value===3?'validation':'commit')
const step = computed(() => {
const status = props.session?.status
if (!status || status === 'created') return 1
if (status === 'uploaded' || status === 'mapping-required') return 2
if (status === 'validating' || status === 'validated') return 3
return 4
})
watch(() => props.session?.id, () => {
localMapping.value = null
mappingName.value = ''
clientIssue.value = ''
commitConfirmOpen.value = false
})
const mappings = computed({
get: () => localMapping.value ?? (props.session?.mapping ?? []),
set: value => { localMapping.value = value },
})
const importableFields = computed(() => props.definition.fields.filter(field => field.importable !== false))
const mappingIssues = computed(() => validateKbxImportMappings(props.definition,mappings.value))
const canValidate = computed(() => mappings.value.length > 0 && mappingIssues.value.length === 0 && !props.busy)
const canCommit = computed(() => (props.session?.validRows ?? 0) > 0 && props.session?.status === 'validated' && !props.busy)
const isFailed = computed(() => props.session?.status === 'failed')
const isCancelled = computed(() => props.session?.status === 'cancelled')
const isPartial = computed(() => props.session?.status === 'partially-completed')
function chooseFile() { clientIssue.value=''; fileInput.value?.click() }
function onFiles(files: FileList | null) {
const file = files?.item(0)
if (!file) return
clientIssue.value=''
const issue=validateKbxImportFileCandidate(props.definition,file)
if(issue){clientIssue.value=issue;return}
emit('upload', file)
}
function onDrop(event: DragEvent) {
dragActive.value = false
onFiles(event.dataTransfer?.files ?? null)
}
function setTarget(index: number, targetField: string) {
const next = mappings.value.map((item, itemIndex) => itemIndex === index
? { ...item, targetField: targetField || null, source: 'manual' as const, confidence: undefined, reason:undefined }
: item)
mappings.value = next
emit('saveMapping', next)
}
function requestCommit(){if(canCommit.value)commitConfirmOpen.value=true}
function confirmCommit(){commitConfirmOpen.value=false;emit('commit')}
</script>
<template>
<section class="kbx-import" data-kbx-component="excel-import" :aria-busy="busy || undefined">
<header class="kbx-import__header">
<div>
<h2>{{ definition.title }}</h2>
<p>파일 매핑 검증 반영 순서로 처리합니다. 원본 번호와 오류 이력은 유지됩니다.</p>
</div>
<KbxButton label="업로드 양식 다운로드" variant="secondary" @click="emit('downloadTemplate')" />
</header>
<KbxProgressSteps data-kbx-surface="progress-steps" :steps="importSteps" :current="currentStepKey" />
<div v-if="step === 1" class="kbx-import__drop" data-kbx-surface="file"
:class="{ 'is-dragging': dragActive }"
@dragover.prevent="dragActive = true"
@dragleave.prevent="dragActive = false"
@drop.prevent="onDrop">
<input ref="fileInput" hidden type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" @change="onFiles(($event.target as HTMLInputElement).files)">
<strong>Excel 파일을 선택하세요.</strong>
<span>.xlsx · 최대 {{ Math.round((definition.maxFileSizeBytes ?? 10485760) / 1048576) }}MB</span>
<KbxButton label="파일 선택" variant="primary" :loading="busy" @click="chooseFile" />
<small>Drag & Drop은 보조 기능이며 파일 선택 버튼은 항상 제공됩니다.</small>
<p v-if="clientIssue" class="kbx-import__client-error" role="alert">{{clientIssue}}</p>
</div>
<section v-else-if="step === 2" class="kbx-import__mapping" data-kbx-surface="mapping">
<div class="mapping-head"><span>Excel </span><span>시스템 필드</span><span>매핑 근거</span></div>
<div v-for="(mapping, index) in mappings" :key="mapping.sourceColumn" class="mapping-row">
<strong>{{ mapping.sourceColumn }}</strong>
<select :value="mapping.targetField ?? ''" :aria-invalid="mapping.targetField ? mappings.filter(item=>item.targetField===mapping.targetField).length>1 : undefined" @change="setTarget(index, ($event.target as HTMLSelectElement).value)">
<option value="">매핑하지 않음</option>
<option v-for="field in importableFields" :key="field.key" :value="field.key">
{{ field.label }}{{ field.required ? ' *' : '' }}
</option>
</select>
<span class="mapping-reason">
{{ mapping.source === 'ai' ? `AI 추천 ${Math.round((mapping.confidence ?? 0) * 100)}%` : mapping.reason ?? mapping.source }}
</span>
</div>
<div v-if="mappingIssues.length" class="mapping-issues" role="alert">
<strong>매핑을 확인하세요.</strong>
<ul><li v-for="issue in mappingIssues" :key="`${issue.code}:${issue.fieldKey}`">{{issue.message}}</li></ul>
</div>
<div class="mapping-save">
<label>다음에도 사용할 매핑 이름 <input v-model="mappingName" maxlength="80" placeholder=": 쿠팡 주문양식"></label>
<KbxButton label="매핑 저장" variant="secondary" :disabled="!mappingName.trim() || Boolean(mappingIssues.length)" @click="emit('saveNamedMapping', mappingName.trim(), mappings)" />
</div>
<div class="kbx-import__actions">
<KbxButton label="취소" variant="secondary" @click="emit('cancel')" />
<KbxButton label="검증 시작" variant="primary" :disabled="!canValidate" :loading="busy" @click="emit('validate')" />
</div>
</section>
<section v-else-if="step === 3" class="kbx-import__validation" data-kbx-surface="validation">
<KbxJobProgress v-if="session?.status === 'validating'" :progress="progress ?? null" />
<template v-else>
<div class="summary" aria-label="검증 결과">
<div><span>전체</span><strong>{{ session?.totalRows.toLocaleString() }}</strong></div>
<div><span>정상</span><strong>{{ session?.validRows.toLocaleString() }}</strong></div>
<div><span>오류</span><strong>{{ session?.invalidRows.toLocaleString() }}</strong></div>
<div><span>경고</span><strong>{{ session?.warningRows.toLocaleString() }}</strong></div>
</div>
<div v-if="session?.errors?.length" class="errors">
<div class="errors__head"><span>행</span><span>필드</span><span>사유</span></div>
<div v-for="error in session.errors.slice(0, 100)" :key="`${error.rowNumber}-${error.field}-${error.code}`" class="errors__row" :data-severity="error.severity">
<span>{{ error.rowNumber }}</span><span>{{ error.sourceColumn ?? error.field ?? '-' }}</span><span>{{ error.message }}</span>
</div>
<small v-if="session.errors.length > 100">화면에는 처음 100건만 표시합니다. 전체 오류는 오류파일로 확인하세요.</small>
</div>
<div class="kbx-import__actions">
<KbxButton v-if="session?.invalidRows" label="오류파일 다운로드" variant="secondary" @click="emit('downloadErrors')" />
<KbxButton label="정상 데이터 반영" variant="primary" :disabled="!canCommit" @click="requestCommit" />
</div>
</template>
</section>
<section v-else class="kbx-import__commit" data-kbx-surface="result" :data-result="session?.status">
<KbxJobProgress v-if="session?.status === 'committing'" :progress="progress ?? null" />
<div v-else-if="isFailed" class="kbx-import__terminal is-error" role="alert">
<strong>{{session?.failure?.title ?? '반영 작업을 완료하지 못했습니다.'}}</strong>
<p>{{session?.failure?.detail ?? '원본 업로드와 작업 이력은 유지됩니다. 원인을 확인한 뒤 다시 진행하세요.'}}</p>
<small v-if="session?.failure?.code">오류코드 {{session.failure.code}}</small>
<div class="kbx-import__actions"><KbxButton label=" 파일로 다시 시작" variant="secondary" @click="emit('cancel')" /></div>
</div>
<div v-else-if="isCancelled" class="kbx-import__terminal">
<strong>반영 작업이 취소되었습니다.</strong><p>새 파일을 선택해 다시 시작할 수 있습니다.</p>
<div class="kbx-import__actions"><KbxButton label=" 파일 선택" variant="secondary" @click="emit('cancel')" /></div>
</div>
<template v-else>
<div class="summary summary--result" :class="{'is-partial':isPartial}">
<div><span>신규</span><strong>{{ session?.createdRows.toLocaleString() }}</strong></div>
<div><span>수정</span><strong>{{ session?.updatedRows.toLocaleString() }}</strong></div>
<div><span>오류</span><strong>{{ session?.invalidRows.toLocaleString() }}</strong></div>
</div>
<p v-if="isPartial" class="kbx-import__partial">정상 데이터는 반영되었고 오류 데이터는 제외되었습니다. 오류파일로 실패 건만 다시 처리할 수 있습니다.</p>
<div v-if="isPartial && session?.invalidRows" class="kbx-import__actions"><KbxButton label="오류파일 다운로드" variant="secondary" @click="emit('downloadErrors')" /></div>
</template>
</section>
<KbxConfirm
:open="commitConfirmOpen"
title="정상 데이터를 반영하시겠습니까?"
:detail="`${(session?.validRows ?? 0).toLocaleString()}건을 반영합니다. 오류 ${(session?.invalidRows ?? 0).toLocaleString()}건은 제외됩니다.`"
level="high"
:confirm-label="`${(session?.validRows ?? 0).toLocaleString()}건 반영`"
@update:open="commitConfirmOpen=$event"
@confirm="confirmCommit"
/>
</section>
</template>
<style scoped>
.kbx-import{display:grid;gap:var(--kbx-space-4);min-width:0}.kbx-import__header{display:flex;justify-content:space-between;gap:var(--kbx-space-4);align-items:flex-start}.kbx-import__header h2{margin:0 0 var(--kbx-space-1);font-size:var(--kbx-font-xl)}.kbx-import__header p{margin:0;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}.kbx-import__drop{min-height:var(--kbx-import-drop-min-height);border:var(--kbx-border-width) dashed var(--kbx-color-border-strong);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--kbx-space-2);background:var(--kbx-color-surface-muted)}.kbx-import__drop.is-dragging{outline:calc(var(--kbx-border-width) * 2) solid var(--kbx-color-focus);outline-offset:calc(var(--kbx-border-width) * -2)}.kbx-import__drop>span,.kbx-import__drop small{color:var(--kbx-color-text-muted)}.kbx-import__client-error{margin:0;color:var(--kbx-color-danger);font-size:var(--kbx-font-sm)}.kbx-import__mapping,.kbx-import__validation,.kbx-import__commit{border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface);padding:var(--kbx-space-3)}.mapping-head,.mapping-row{display:grid;grid-template-columns:minmax(var(--kbx-import-source-column-min-width),1fr) minmax(var(--kbx-import-target-column-min-width),1.3fr) minmax(var(--kbx-import-reason-column-min-width),.8fr);gap:var(--kbx-space-3);min-height:var(--kbx-control-md);align-items:center;border-bottom:var(--kbx-border-width) solid var(--kbx-color-border)}.mapping-head{font-size:var(--kbx-font-xs);font-weight:600;color:var(--kbx-color-text-muted)}.mapping-row select{height:var(--kbx-control-height);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface)}.mapping-row select[aria-invalid="true"]{border-color:var(--kbx-color-danger);background:var(--kbx-color-danger-surface)}.mapping-reason{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}.mapping-issues{margin-top:var(--kbx-space-3);padding:var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface);font-size:var(--kbx-font-sm)}.mapping-issues ul{margin:var(--kbx-space-2) 0 0;padding-left:var(--kbx-space-5)}.mapping-save{display:flex;align-items:center;justify-content:flex-end;gap:var(--kbx-space-2);padding-top:var(--kbx-space-3)}.mapping-save label{display:flex;align-items:center;gap:var(--kbx-space-2);font-size:var(--kbx-font-sm)}.mapping-save input{width:var(--kbx-import-mapping-name-width);height:var(--kbx-control-height);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-2)}.summary{display:grid;grid-template-columns:repeat(4,minmax(var(--kbx-summary-item-min-width),1fr));border:var(--kbx-border-width) solid var(--kbx-color-border);margin-bottom:var(--kbx-space-3)}.summary>div{padding:var(--kbx-space-3);display:flex;justify-content:space-between;border-right:var(--kbx-border-width) solid var(--kbx-color-border)}.summary>div:last-child{border-right:0}.summary strong{font-size:var(--kbx-font-xl)}.summary--result{grid-template-columns:repeat(3,minmax(var(--kbx-summary-item-min-width),1fr))}.summary--result.is-partial{border-color:var(--kbx-color-warning-border)}.errors{border:var(--kbx-border-width) solid var(--kbx-color-border)}.errors__head,.errors__row{display:grid;grid-template-columns:var(--kbx-import-error-row-width) var(--kbx-import-error-field-width) 1fr;gap:var(--kbx-space-2);min-height:var(--kbx-control-height);align-items:center;padding:0 var(--kbx-space-2);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);font-size:var(--kbx-font-sm)}.errors__head{font-weight:600;background:var(--kbx-color-surface-muted)}.errors__row[data-severity="warning"]{background:var(--kbx-color-warning-surface)}.errors__row[data-severity="error"]{background:var(--kbx-color-danger-surface)}.errors__row:last-of-type{border-bottom:0}.errors small{display:block;padding:var(--kbx-space-2);color:var(--kbx-color-text-muted)}.kbx-import__terminal{display:grid;gap:var(--kbx-space-2);padding:var(--kbx-space-4);background:var(--kbx-color-surface-muted);border:var(--kbx-border-width) solid var(--kbx-color-border)}.kbx-import__terminal.is-error{background:var(--kbx-color-danger-surface);border-color:var(--kbx-color-danger-border)}.kbx-import__terminal p,.kbx-import__partial{margin:0;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}.kbx-import__actions{display:flex;justify-content:flex-end;gap:var(--kbx-space-2);margin-top:var(--kbx-space-3)}
</style>