o2o-negosium-original/negodata/front/src/features/cards/components/CardExcelUploadModal.tsx
Mina Choi c62b66e35b [feat] negodata: 소유권 게이팅 + 견적 낙찰 + 작성자명 표시 + 전화입력 + 카드 엑셀
- 소유권 게이팅(common/authz): 변경 액션 본인∪OWNER, 협력사 삭제 OWNER 전용
- 견적 수동 낙찰(award) + 작성자명(creatorName) 표시 + 전화번호 입력 컴포넌트 + 카드 엑셀 업로드
- supplier_type 은 이번 커밋 미변경(다음 커밋에서 코드부터 정리 예정)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:18:33 +09:00

373 lines
18 KiB
TypeScript

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<string, string>): 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<string | null>(null);
const [rows, setRows] = useState<RawRow[]>([]);
const [serverErrors, setServerErrors] = useState<Record<string, string>>({});
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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<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}건 서버 검증 실패`, '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-6xl 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-cards-file-input"
accept=".csv,.xls,.xlsx"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFile(file);
}}
/>
<button
type="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-auto max-h-60">
<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 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 w-16"></TableHead>
<TableHead className="p-2 font-semibold w-16"></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 text-[10px] text-muted-foreground whitespace-nowrap">
{isWild(row.kind) ? '와일드' : '협상'}
</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 font-semibold"
value={row.title}
onChange={(e) => handleUpdateField(row.id, 'title', e.target.value)}
/>
</TableCell>
<TableCell className="p-2">
<Input
type="text"
className="bg-muted/20 hover:bg-muted/50 text-foreground"
value={row.script}
onChange={(e) => handleUpdateField(row.id, 'script', e.target.value)}
placeholder="협상 스크립트(평문)"
/>
</TableCell>
<TableCell className="p-2 text-[10px] text-muted-foreground whitespace-nowrap">
{usageCode(row.usage) === CardUsageType.NEW ? '신규' : usageCode(row.usage) === CardUsageType.REUSE ? '재' : '공통'}
</TableCell>
<TableCell className="p-2 text-[10px] text-muted-foreground whitespace-nowrap">
{isShared(row.scope) ? '전체' : '개인'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Typography variant="muted" className="text-[10px]">
· . · .
</Typography>
</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-cards-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>
);
}
// 업로드 양식(.csv) 다운로드 — 채워 넣을 컬럼 헤더 + 예시 2행(협상/와일드). 툴바·모달이 공유한다.
export function downloadCardTemplate() {
downloadExcel<TemplateRow>(
'협상카드_업로드_양식',
[
{ 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: '특정 원재료 포함 입찰에만 적용' },
],
);
}