o2o-negosium-original/negodata/front/src/features/partners/components/ExcelUploadModal.tsx

425 lines
21 KiB
TypeScript

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<PartnerBulkResult>;
onClose: () => void;
};
// 행 검증 — 순수 함수. 우선순위 순으로 첫 위반 메시지를 매긴다.
// serverErrors: 서버(DB) 검증에서 거부된 code→사유. 프론트 검증을 통과한 행만 마지막에 덧씌운다.
function validateRows(
rows: RawRow[],
partners: Partner[],
serverErrors: Record<string, string>,
): 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<TemplateRow>(
'협력사_업로드_양식',
[
{ 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<Partner>(
`협력사_목록_${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<string | null>(null);
const [rows, setRows] = useState<RawRow[]>([]);
const [serverErrors, setServerErrors] = useState<Record<string, string>>({}); // 서버(DB) 거부 code→사유
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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<string, string>[];
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<string, string> = {};
(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<string, string> = {};
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
<div className="w-full max-w-5xl bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono">
{/* Modal Title */}
<div className="flex items-center justify-between pb-4 border-b border-border">
<div className="flex items-center gap-2 text-foreground">
<FileSpreadsheet className="text-muted-foreground" size={20} />
<Typography variant="h3">협력사 엑셀 일괄 검증 및 등록</Typography>
</div>
<button
onClick={close}
className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer"
>
<X size={18} />
</button>
</div>
{/* File select + drop */}
<div className="my-6">
{!excelFile ? (
<div
onDragOver={(e) => { 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'
}`}
>
<Upload size={32} className="text-muted-foreground mb-3" />
<Typography variant="small" className="font-semibold">
협력사 리스트 엑셀 파일을 업로드해 주십시오
</Typography>
<Typography variant="muted" className="text-[10px] mt-1.5 mb-4">
협력사 코드 중복 및 필수 항목 검사를 수행합니다.
</Typography>
<input
ref={fileInputRef}
type="file"
id="excel-partners-file-input"
accept=".csv,.xls,.xlsx"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFile(file);
}}
/>
<button
type="button"
id="excel-partners-simulation-button"
onClick={() => fileInputRef.current?.click()}
className="py-1.5 px-3 bg-foreground text-background text-xs font-semibold rounded cursor-pointer hover:opacity-95"
>
엑셀 파일 업로드하기
</button>
</div>
) : (
<div className="space-y-4">
{/* File meta */}
<div className="flex items-center justify-between p-3 rounded bg-emerald-500/10 border border-emerald-500/30 text-xs">
<div className="flex items-center gap-2 text-foreground">
<CheckCircle2 className="text-emerald-500" size={16} />
<span>{excelFile}</span>
</div>
<span className="text-[10px] text-muted-foreground font-mono">{validated.length}개 데이터 행 검출</span>
</div>
{/* Previews table and validation */}
<div className="space-y-1.5">
<span className="text-xs font-bold text-foreground block">엑셀 정밀 유효값 진단</span>
<div className="border border-border rounded overflow-hidden max-h-60 overflow-y-auto">
<Table className="w-full text-left font-mono text-[11px] border-collapse bg-background">
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
<TableRow>
<TableHead className="p-2 font-semibold w-12 text-center">행</TableHead>
<TableHead className="p-2 font-semibold text-center w-12">삭제</TableHead>
<TableHead className="p-2 font-semibold text-center w-16">자격</TableHead>
<TableHead className="p-2 font-semibold">진단 내용</TableHead>
<TableHead className="p-2 font-semibold">협력사명 *</TableHead>
<TableHead className="p-2 font-semibold">협력사코드 *</TableHead>
<TableHead className="p-2 font-semibold">담당자명 *</TableHead>
<TableHead className="p-2 font-semibold">담당자 이메일 *</TableHead>
<TableHead className="p-2 font-semibold">취급상품 (,로 구분)</TableHead>
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-border">
{validated.map((row) => (
<TableRow key={row.id} className={row.status === '오류' ? 'bg-red-500/5 hover:bg-red-500/10' : 'bg-emerald-500/5 hover:bg-emerald-500/10'}>
<TableCell className="p-2 text-center text-muted-foreground">{row.rowNum}</TableCell>
<TableCell className="p-2 text-center">
<button
type="button"
onClick={() => handleRemoveRow(row.id)}
title="이 행 삭제"
className="p-1 rounded text-muted-foreground hover:text-rose-600 hover:bg-rose-500/10 cursor-pointer"
>
<Trash2 size={14} />
</button>
</TableCell>
<TableCell className="p-2 text-center">
<span className={`px-1.5 py-0.5 rounded text-[9px] font-bold block ${
row.status === '정상'
? 'bg-emerald-100 text-emerald-800 border border-emerald-300 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-800/80'
: 'bg-red-100 text-red-800 border border-red-300 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-950'
}`}>
{row.status}
</span>
</TableCell>
<TableCell className={`p-2 font-mono text-[10px] ${row.status === '오류' ? 'text-rose-500' : 'text-emerald-600'}`}>
{row.message}
</TableCell>
<TableCell className="p-2">
<Input
type="text"
className="bg-muted/20 hover:bg-muted/50 text-foreground font-semibold"
value={row.name}
onChange={(e) => handleUpdateField(row.id, 'name', e.target.value)}
/>
</TableCell>
<TableCell className="p-2">
<Input
type="text"
className="bg-muted/20 hover:bg-muted/50 text-foreground font-mono"
value={row.code}
onChange={(e) => handleUpdateField(row.id, 'code', e.target.value)}
/>
</TableCell>
<TableCell className="p-2">
<Input
type="text"
className="bg-muted/20 hover:bg-muted/50 text-foreground"
value={row.managerName}
onChange={(e) => handleUpdateField(row.id, 'managerName', e.target.value)}
/>
</TableCell>
<TableCell className="p-2">
<Input
type="text"
className="bg-muted/20 hover:bg-muted/50 text-foreground font-mono"
value={row.managerEmail}
onChange={(e) => handleUpdateField(row.id, 'managerEmail', e.target.value)}
/>
</TableCell>
<TableCell className="p-2">
<Input
type="text"
className="bg-muted/20 hover:bg-muted/50 text-foreground"
value={row.products}
onChange={(e) => handleUpdateField(row.id, 'products', e.target.value)}
placeholder="상품명, 상품명"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
)}
</div>
{/* Excel footer */}
<div className="flex items-center justify-between pt-4 border-t border-border mt-6 text-xs bg-muted/40 p-3 rounded">
<span className="font-mono text-muted-foreground">
적격 대상사: {validCount}개소 // 비적격 차단: {errorCount}개소
</span>
<div className="flex gap-2">
<button
type="button"
onClick={close}
className="py-1.5 px-3 border border-border rounded hover:bg-muted text-foreground cursor-pointer text-xs"
>
중단
</button>
<button
type="button"
id="excel-partners-confirm-button"
disabled={validCount === 0}
onClick={handleConfirm}
className="py-1.5 px-4 bg-primary text-primary-foreground font-bold rounded hover:opacity-95 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer text-xs"
>
적격 협력사 등록 (총 {validCount}개사)
</button>
</div>
</div>
</div>
</div>
);
}