- 소유권 게이팅(common/authz): 변경 액션 본인∪OWNER, 협력사 삭제 OWNER 전용 - 견적 수동 낙찰(award) + 작성자명(creatorName) 표시 + 전화번호 입력 컴포넌트 + 카드 엑셀 업로드 - supplier_type 은 이번 커밋 미변경(다음 커밋에서 코드부터 정리 예정) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
373 lines
18 KiB
TypeScript
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: '특정 원재료 포함 입찰에만 적용' },
|
|
],
|
|
);
|
|
}
|