import { useMemo, useRef, useState } from 'react'; import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react'; import { useScrollLock } from '@/lib/useScrollLock'; import type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier'; import { showToast } from '@/lib/notify'; import { downloadExcel, readSpreadsheetRows, todayStamp, type BulkFailure } from '@/lib/excel'; import { customFetch } from '@/api/mutator/custom-fetch'; import { Typography } from '@/components/ui/typography'; import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import type { Partner } from '../types'; // 엑셀에서 읽어온 원본 행(입력값만). status/message는 저장하지 않고 검증에서 파생한다. type RawRow = { id: string; rowNum: number; name: string; code: string; managerName: string; managerEmail: string; totalRevenue: string; products: string; // 취급상품 — 상품명 콤마(,) 나열. 업로드 시 매핑테이블로 들어간다. }; type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string }; // 업로드 양식 한 줄(예시 행) type TemplateRow = { name: string; code: string; managerName: string; managerEmail: string; totalRevenue: string; products: string }; // 협력사 1건 + 그 협력사에 매핑할 취급상품명 리스트. export type PartnerUploadRow = { supplier: SupplierCreate; products: string[] }; // 일괄 등록 결과 — 협력사 등록 실패(행별) + 이름 미매칭으로 건너뛴 취급상품명들. export type PartnerBulkResult = { failures: BulkFailure[]; unmatchedProducts: string[] }; // "상품A, 상품B" → ['상품A','상품B'] (공백/빈값 제거). const splitNames = (s: string): string[] => s.split(',').map((t) => t.trim()).filter(Boolean); type ExcelUploadModalProps = { open: boolean; partners: Partner[]; // 코드 중복 검사용 onConfirm: (rows: PartnerUploadRow[]) => Promise; onClose: () => void; }; // 행 검증 — 순수 함수. 우선순위 순으로 첫 위반 메시지를 매긴다. // serverErrors: 서버(DB) 검증에서 거부된 code→사유. 프론트 검증을 통과한 행만 마지막에 덧씌운다. function validateRows( rows: RawRow[], partners: Partner[], serverErrors: Record, ): ValidatedRow[] { return rows.map((row) => { const fail = (message: string): ValidatedRow => ({ ...row, status: '오류', message }); if (!row.name.trim()) return fail('유효성 위반 - 협력사명을 입력해 주십시오.'); if (!row.code.trim()) return fail('유효성 위반 - 식별코드를 입력해 주십시오.'); const dupInPartners = partners.some((p) => p.code === row.code && !p.deleted); const dupInExcel = rows.some((other) => other.id !== row.id && other.code === row.code); if (dupInPartners || dupInExcel) return fail('코드 중복 - 이미 존재하거나 목록 내 중복된 코드입니다.'); if (!row.managerEmail.trim() || !row.managerEmail.includes('@')) { return fail('이메일 형식 규격 외 - 올바른 이메일 주소(@ 포함)가 필요합니다.'); } // 프론트 검증 통과 후, 직전 전송에서 서버가 거부한 코드면 그 사유로 오류 처리. if (serverErrors[row.code]) return fail(serverErrors[row.code]); return { ...row, status: '정상', message: '등록 적격 - 정합성 검증 통과' }; }); } // 검증된(정상) 행 → 서버 생성 payload. function toSupplierCreate(row: RawRow): SupplierCreate { return { name: row.name, code: row.code, manager_name: row.managerName, manager_email: row.managerEmail, manager_contact_number: '01000000000', // 양식에 연락처 컬럼 없음 → placeholder(숫자만 저장 컨벤션) total_revenue: row.totalRevenue?.trim() ? Number(row.totalRevenue.replace(/[^0-9]/g, '')) : undefined, }; } // 업로드 양식(.csv) 다운로드 — 채워 넣을 컬럼 헤더 + 예시 1행. 툴바·모달이 공유한다. export function downloadPartnerTemplate() { downloadExcel( '협력사_업로드_양식', [ { header: '협력사명', value: (r) => r.name }, { header: '식별코드', value: (r) => r.code }, { header: '담당자명', value: (r) => r.managerName }, { header: '담당자이메일', value: (r) => r.managerEmail }, { header: '총매출액', value: (r) => r.totalRevenue }, { header: '취급상품', value: (r) => r.products }, ], [{ name: '예시) (주)한빛정밀', code: 'PART-EXAMPLE-001', managerName: '김철수 과장', managerEmail: 'cs.kim@example.com', totalRevenue: '5000000000', products: '고압 에어 컴프레서, 스테인리스 볼밸브' }], ); } // 서버의 실제 협력사 데이터를 양식과 동일한 헤더로 내보낸다(가져와 수정 → 재업로드 라운드트립). // 취급상품은 목록 응답에 없어 빈칸으로 둔다 — 재업로드는 매핑을 추가만 하므로 비파괴. export function downloadPartnerData(partners: Partner[]) { downloadExcel( `협력사_목록_${todayStamp()}`, [ { header: '협력사명', value: (p) => p.name }, { header: '식별코드', value: (p) => p.code ?? '' }, { header: '담당자명', value: (p) => p.manager_name ?? '' }, { header: '담당자이메일', value: (p) => p.manager_email ?? '' }, { header: '총매출액', value: (p) => p.total_revenue ?? '' }, { header: '취급상품', value: () => '' }, ], partners, ); } // 협력사 엑셀 일괄 업로드 모달. 파일 파싱·원본 행 state는 이 컴포넌트가 소유하고, // 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임. export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUploadModalProps) { useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금 const [excelFile, setExcelFile] = useState(null); const [rows, setRows] = useState([]); const [serverErrors, setServerErrors] = useState>({}); // 서버(DB) 거부 code→사유 const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); const validated = useMemo( () => validateRows(rows, partners, serverErrors), [rows, partners, serverErrors], ); const validRows = validated.filter((r) => r.status === '정상'); const validCount = validRows.length; const errorCount = validated.length - validCount; if (!open) return null; const close = () => { setExcelFile(null); setRows([]); setServerErrors({}); onClose(); }; // 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). 헤더는 양식과 동일해야 함. const handleFile = async (file: File) => { let parsed: Record[]; try { parsed = await readSpreadsheetRows(file); } catch (err) { showToast(err instanceof Error ? err.message : '파일을 읽을 수 없습니다.', 'error'); return; } const loaded: RawRow[] = parsed.map((r, i) => ({ id: `row-${i + 1}`, rowNum: i + 2, name: r['협력사명'] ?? '', code: r['식별코드'] ?? '', managerName: r['담당자명'] ?? '', managerEmail: r['담당자이메일'] ?? '', totalRevenue: r['총매출액'] ?? '', products: r['취급상품'] ?? '', })); setExcelFile(file.name); setRows(loaded); setServerErrors({}); // 새 파일 → 직전 서버사유 초기화 // 업로드 즉시 DB 중복코드 사전검사 → 미리보기에서 바로 빨강 처리. const codes = [...new Set(loaded.map((r) => r.code.trim()).filter(Boolean))]; if (codes.length === 0) return; try { const res = await customFetch<{ existing?: string[] }>({ url: '/v1/supplier/check-codes', method: 'POST', headers: { 'Content-Type': 'application/json' }, data: { codes }, }); const dup: Record = {}; (res.existing ?? []).forEach((c) => { dup[c] = '코드 중복 — 이미 등록된 협력사코드입니다(DB).'; }); setServerErrors(dup); } catch { // 사전검사 호출 실패는 조용히 무시 — 제출 시 서버가 최종 차단한다. } }; // 인라인 편집 — 원본 필드만 갱신(재검증은 파생이 처리) const handleUpdateField = (id: string, field: 'name' | 'code' | 'managerName' | 'managerEmail' | 'products', value: string) => { setRows((cur) => cur.map((row) => (row.id === id ? { ...row, [field]: value } : row))); }; // 행 삭제 — 정상/오류 무관하게 미리보기에서 제거(서버 등록 전 단계). const handleRemoveRow = (id: string) => { setRows((cur) => cur.filter((row) => row.id !== id)); }; const handleConfirm = async () => { if (validRows.length === 0) { showToast('정합성이 무결한 파트너 행이 존재하지 않습니다.', 'error'); return; } try { const { failures, unmatchedProducts } = await onConfirm( validRows.map((r) => ({ supplier: toSupplierCreate(r), products: splitNames(r.products) })), ); const okCount = validRows.length - failures.length; // 이름이 회사 상품목록에 없어 매핑 못한 취급상품 — 스킵하고 결과에 부기(결정: 미매칭은 스킵+리포트). const unmatchedNote = unmatchedProducts.length ? ` · 미매칭 취급상품 ${unmatchedProducts.length}건 건너뜀(${unmatchedProducts.slice(0, 5).join(', ')}${unmatchedProducts.length > 5 ? '…' : ''})` : ''; if (failures.length === 0) { showToast(`총 ${okCount}개 협력사가 서버에 일괄 등록되었습니다.${unmatchedNote}`, unmatchedProducts.length ? 'info' : 'success'); close(); return; } // 부분 성공: 등록 성공한 행만 제거하고, 서버(DB)가 거부한 행은 사유와 함께 남긴다. const failMap: Record = {}; failures.forEach((f) => { failMap[f.code] = f.message; }); const okCodes = new Set(validRows.map((r) => r.code).filter((c) => failMap[c] === undefined)); setServerErrors(failMap); setRows((cur) => cur.filter((r) => !okCodes.has(r.code))); showToast(`${okCount}건 등록 완료 · ${failures.length}건 서버 검증 실패(중복코드 등)${unmatchedNote}`, 'error'); } catch (err) { showToast(err instanceof Error ? err.message : '엑셀 일괄 등록 실패', 'error'); } }; return (
{/* Modal Title */}
협력사 엑셀 일괄 검증 및 등록
{/* File select + drop */}
{!excelFile ? (
{ e.preventDefault(); setIsDragging(true); }} onDragLeave={() => setIsDragging(false)} onDrop={(e) => { e.preventDefault(); setIsDragging(false); const file = e.dataTransfer.files?.[0]; if (file) handleFile(file); }} className={`border-2 border-dashed rounded-lg p-8 flex flex-col items-center justify-center transition-colors ${ isDragging ? 'border-primary bg-primary/10' : 'border-border bg-muted/30' }`} > 협력사 리스트 엑셀 파일을 업로드해 주십시오 협력사 코드 중복 및 필수 항목 검사를 수행합니다. { const file = e.target.files?.[0]; if (file) handleFile(file); }} />
) : (
{/* File meta */}
{excelFile}
{validated.length}개 데이터 행 검출
{/* Previews table and validation */}
엑셀 정밀 유효값 진단
행 삭제 자격 진단 내용 협력사명 * 협력사코드 * 담당자명 * 담당자 이메일 * 취급상품 (,로 구분) {validated.map((row) => ( {row.rowNum} {row.status} {row.message} handleUpdateField(row.id, 'name', e.target.value)} /> handleUpdateField(row.id, 'code', e.target.value)} /> handleUpdateField(row.id, 'managerName', e.target.value)} /> handleUpdateField(row.id, 'managerEmail', e.target.value)} /> handleUpdateField(row.id, 'products', e.target.value)} placeholder="상품명, 상품명" /> ))}
)}
{/* Excel footer */}
적격 대상사: {validCount}개소 // 비적격 차단: {errorCount}개소
); }