import { useMemo, useRef, useState } from 'react'; import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react'; import { useScrollLock } from '@/lib/useScrollLock'; import { showToast } from '@/lib/notify'; import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel'; import { Typography } from '@/components/ui/typography'; import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { CardUsageType } from '@/api/generated/model'; import { deserialize } from '../editor'; import type { CardInput } from '../hooks/useCards'; // 엑셀에서 읽어온 원본 행(입력값만). status/message는 검증에서 파생한다. type RawRow = { id: string; rowNum: number; kind: string; // 카드종류 원문(협상/와일드) code: string; // 카드번호 title: string; // 카드이름 script: string; // 스크립트(평문 — 변수는 {변수} 텍스트로) usage: string; // 카드용도 원문(공통/신규견적전용/재견적전용) scope: string; // 공개범위 원문(개인/전체) condition: string; // 와일드 전용 사용조건 memo: string; // 와일드 전용 메모 }; type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string }; type TemplateRow = { kind: string; code: string; title: string; script: string; usage: string; scope: string; condition: string; memo: string }; // 카드종류 원문 → 와일드 여부. '와일드'/'wild' 포함이면 와일드, 그 외 협상. const isWild = (kind: string) => /와일드|wild/i.test(kind.trim()); // 공개범위 원문 → 전체(공용) 여부. '전체'/'공용'/'all' 이면 공용, 그 외 개인. const isShared = (scope: string) => /전체|공용|all/i.test(scope.trim()); // 카드용도 원문 → CardUsageType 코드. '신규'→NEW '재'→REUSE 그 외 COMMON. function usageCode(usage: string): number { const u = usage.trim(); if (/신규/.test(u)) return CardUsageType.NEW; if (/재/.test(u)) return CardUsageType.REUSE; return CardUsageType.COMMON; } type CardExcelUploadModalProps = { open: boolean; onConfirm: (rows: CardInput[]) => Promise<{ failures: BulkFailure[] }>; onClose: () => void; }; // 행 검증 — 순수 함수. 우선순위 순으로 첫 위반 메시지를 매긴다. // serverErrors: 서버가 거부한 code(카드번호)→사유. 프론트 검증 통과 행에만 마지막에 덧씌운다. function validateRows(rows: RawRow[], serverErrors: Record): ValidatedRow[] { return rows.map((row) => { const fail = (message: string): ValidatedRow => ({ ...row, status: '오류', message }); if (!row.code.trim()) return fail('유효성 위반 - 카드번호를 입력해 주십시오.'); if (!row.title.trim()) return fail('유효성 위반 - 카드이름을 입력해 주십시오.'); if (!row.script.trim()) return fail('유효성 위반 - 스크립트를 입력해 주십시오.'); if (serverErrors[row.code]) return fail(serverErrors[row.code]); const kind = isWild(row.kind) ? '와일드' : '협상'; const scope = isShared(row.scope) ? '전체' : '개인'; return { ...row, status: '정상', message: `등록 적격 - ${kind}카드 · ${scope}` }; }); } // 검증된(정상) 행 → 서버 등록용 CardInput. 스크립트 평문은 Slate 노드로 복원(변수칩 재현). function toCardInput(row: RawRow): CardInput { const wild = isWild(row.kind); return { title: row.title, code: row.code, editorScript: deserialize(undefined, row.script), status: 'ACTIVE', isWildcard: wild, isShared: isShared(row.scope), usageType: usageCode(row.usage), triggerCondition: wild ? row.condition : undefined, memo: wild ? row.memo : undefined, }; } // 카드 엑셀 일괄 업로드 모달. 파일 파싱·원본 행 state는 이 컴포넌트가 소유하고, // 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임. export function CardExcelUploadModal({ open, onConfirm, onClose }: CardExcelUploadModalProps) { useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금 const [excelFile, setExcelFile] = useState(null); const [rows, setRows] = useState([]); const [serverErrors, setServerErrors] = useState>({}); const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); const validated = useMemo(() => validateRows(rows, serverErrors), [rows, 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) => { const parsed = parseCsv(await file.text()); const loaded: RawRow[] = parsed.map((r, i) => ({ id: `row-${i + 1}`, rowNum: i + 2, kind: r['카드종류'] ?? '', code: r['카드번호'] ?? '', title: r['카드이름'] ?? '', script: r['스크립트'] ?? '', usage: r['카드용도'] ?? '', scope: r['공개범위'] ?? '', condition: r['사용조건'] ?? '', memo: r['메모'] ?? '', })); setExcelFile(file.name); setRows(loaded); setServerErrors({}); }; const handleUpdateField = (id: string, field: 'code' | 'title' | 'script', 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 } = await onConfirm(validRows.map(toCardInput)); const okCount = validRows.length - failures.length; if (failures.length === 0) { showToast(`총 ${okCount}개 카드가 서버에 일괄 등록되었습니다.`, 'success'); close(); return; } // 부분 성공: 등록 성공한 행만 제거하고, 서버가 거부한 행은 사유와 함께 남긴다. 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}건 서버 검증 실패`, '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} {isWild(row.kind) ? '와일드' : '협상'} handleUpdateField(row.id, 'code', e.target.value)} /> handleUpdateField(row.id, 'title', e.target.value)} /> handleUpdateField(row.id, 'script', e.target.value)} placeholder="협상 스크립트(평문)" /> {usageCode(row.usage) === CardUsageType.NEW ? '신규' : usageCode(row.usage) === CardUsageType.REUSE ? '재' : '공통'} {isShared(row.scope) ? '전체' : '개인'} ))}
※ 와일드카드의 사용조건·메모는 표에 표시되지 않지만 업로드에 함께 반영됩니다. 스크립트의 굵게·색상 서식은 평문 등록 후 카드 편집에서 지정하세요.
)}
{/* Excel footer */}
적격 카드: {validCount}개 // 비적격 차단: {errorCount}개
); } // 업로드 양식(.csv) 다운로드 — 채워 넣을 컬럼 헤더 + 예시 2행(협상/와일드). 툴바·모달이 공유한다. export function downloadCardTemplate() { downloadExcel( '협상카드_업로드_양식', [ { header: '카드종류', value: (r) => r.kind }, { header: '카드번호', value: (r) => r.code }, { header: '카드이름', value: (r) => r.title }, { header: '스크립트', value: (r) => r.script }, { header: '카드용도', value: (r) => r.usage }, { header: '공개범위', value: (r) => r.scope }, { header: '사용조건', value: (r) => r.condition }, { header: '메모', value: (r) => r.memo }, ], [ { kind: '협상', code: 'CARD-EX-01', title: '예시) 최우수 등급 부여 카드', script: '귀사를 최우수 협력사로 지정하여 {목표가} 조건을 제안드립니다.', usage: '공통', scope: '개인', condition: '', memo: '' }, { kind: '와일드', code: 'WILD-EX-01', title: '예시) 원자재 급등 대응 카드', script: '원자재 시세 급등에 따른 단가 재조정을 요청드립니다.', usage: '공통', scope: '전체', condition: '원부자재 시세가 계약일 대비 3.5% 상회 시', memo: '특정 원재료 포함 입찰에만 적용' }, ], ); }