425 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|