feat: Add Smart Excel Upload functionality for item inspection

- Introduced a new SmartExcelUploadModal component to facilitate bulk item inspection uploads via Excel.
- Implemented logic for downloading templates, validating uploaded files, and parsing data for inspection criteria.
- Enhanced the item inspection page to support dynamic loading of item process mappings and reference data for improved user experience.
- Added necessary types and utility functions for template generation and parsing, ensuring robust handling of Excel data.
- These changes aim to streamline the item inspection process and improve data management across multiple company implementations.
This commit is contained in:
kjs
2026-04-15 14:23:44 +09:00
parent 7aaf264661
commit dffa16f3e5
9 changed files with 2271 additions and 2 deletions

View File

@@ -372,6 +372,69 @@ export async function getRoutingVersions(req: AuthenticatedRequest, res: Respons
}
}
// ─── 품목별 라우팅 벌크 조회 (엑셀 업로드용) ───
export async function getRoutingVersionsBulk(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { itemCodes } = req.body as { itemCodes: string[] };
if (!itemCodes || !Array.isArray(itemCodes) || itemCodes.length === 0) {
return res.json({ success: true, data: {} });
}
const pool = getPool();
const result: Record<string, { code: string; name: string }[]> = {};
// 청크 단위로 분할 (PostgreSQL placeholder 제한 대응)
const CHUNK_SIZE = 5000;
for (let ci = 0; ci < itemCodes.length; ci += CHUNK_SIZE) {
const chunk = itemCodes.slice(ci, ci + CHUNK_SIZE);
// 1. 기본 라우팅 버전 조회
const placeholders = chunk.map((_, i) => `$${i + 2}`).join(",");
const versionsResult = await pool.query(
`SELECT DISTINCT ON (item_code) id, item_code, version_name
FROM item_routing_version
WHERE company_code = $1 AND item_code IN (${placeholders})
ORDER BY item_code, is_default DESC, created_date DESC`,
[companyCode, ...chunk]
);
if (versionsResult.rows.length === 0) continue;
// 2. 라우팅 디테일 조회
const versionIds = versionsResult.rows.map((v: any) => v.id);
const vPlaceholders = versionIds.map((_: any, i: number) => `$${i + 2}`).join(",");
const detailsResult = await pool.query(
`SELECT rd.routing_version_id, rd.process_code,
COALESCE(p.process_name, rd.process_code) AS process_name
FROM item_routing_detail rd
LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code
WHERE rd.company_code = $1 AND rd.routing_version_id IN (${vPlaceholders})
ORDER BY rd.seq_no::integer`,
[companyCode, ...versionIds]
);
// 3. 매핑
const versionToItem: Record<string, string> = {};
for (const v of versionsResult.rows) {
versionToItem[v.id] = v.item_code;
}
for (const d of detailsResult.rows) {
const itemCode = versionToItem[d.routing_version_id];
if (!itemCode) continue;
if (!result[itemCode]) result[itemCode] = [];
result[itemCode].push({ code: d.process_code, name: d.process_name });
}
}
return res.json({ success: true, data: result });
} catch (error: any) {
logger.error("벌크 라우팅 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 작업지시 라우팅 변경 ───
export async function updateRouting(req: AuthenticatedRequest, res: Response) {
try {

View File

@@ -15,6 +15,9 @@ router.get("/source/production-plan", ctrl.getProductionPlanSource);
router.get("/equipment", ctrl.getEquipmentList);
router.get("/employees", ctrl.getEmployeeList);
// 벌크 라우팅 조회 (품목별 공정 일괄 조회)
router.post("/routing-versions-bulk", ctrl.getRoutingVersionsBulk);
// 라우팅 & 공정작업기준
router.get("/:wiNo/routing-versions/:itemCode", ctrl.getRoutingVersions);
router.put("/:wiNo/routing", ctrl.updateRouting);

337
docs/smart-excel-upload.md Normal file
View File

@@ -0,0 +1,337 @@
# SmartExcelUpload
설정(Config) 기반 엑셀 업로드 공통 모듈. Config 객체와 데이터를 넘기면 템플릿 생성, 업로드, 검증, 미리보기까지 자동 처리된다. 화면별로 Config 정의 + 데이터 조회 + 저장 콜백만 작성하면 어디든 적용 가능.
## 기존 ExcelUploadModal과의 차이
기존 `ExcelUploadModal`은 단일 테이블의 단순 데이터를 일괄 업로드하는 용도(거래처 목록 등).
`SmartExcelUpload`는 아래와 같이 **기존 컴포넌트로 처리하기 어려운 복잡한 구조**에서 사용한다.
| 상황 | 예시 |
|------|------|
| 셀 간 연동이 필요할 때 | A 컬럼 선택 → B 컬럼 자동 입력 |
| 선택한 값에 따라 드롭다운이 달라질 때 | 마스터 데이터별로 선택 가능한 하위 항목이 다름 |
| 조건에 따라 입력 가능/불가가 바뀔 때 | 특정 유형일 때만 특정 컬럼 입력 가능 |
| 멀티 시트로 유형을 구분할 때 | 유형별 시트 분리 |
| 참조 데이터 기반 자동 검증이 필요할 때 | 기준 데이터 변경 시 템플릿 재다운로드 유도 (해시 검증) |
| 1:N 관계의 데이터를 등록할 때 | 마스터 1개에 디테일 N개, 유형 다수 |
단순 일괄 등록은 기존 `ExcelUploadModal`을 쓰면 된다.
> **참고**: 셀 간 연동 수식(VLOOKUP, INDEX/MATCH 등)은 화면마다 다르다. SmartExcelUpload가 제공하는 것은 수식 자체가 아니라 **수식을 적용하는 메커니즘**(autoFill, customFormula, INDIRECT 등)이다. 어떤 컬럼에 어떤 수식이 들어갈지는 Config에서 화면별로 정의한다.
## 파일 구조
```
SmartExcelUpload/
index.ts # export
types.ts # Config 인터페이스, 검증 타입
templateGenerator.ts # ExcelJS 기반 엑셀 템플릿 생성
templateParser.ts # 업로드 파일 파싱 + 해시 검증 + 데이터 검증
SmartExcelUploadModal.tsx # 모달 UI (다운로드 → 업로드 → 검증 → 미리보기)
```
## 핵심 개념
### Config 기반 동작
모든 동작은 `SmartExcelUploadConfig`로 결정된다.
```typescript
const config: SmartExcelUploadConfig = {
templateName: "파일명",
sheets: [...], // 시트 정의 (단일/멀티)
referenceSheet: {...}, // 참조 데이터 숨김시트 (선택)
conditionalRules: [...], // 조건부 검증 규칙 (선택)
indirectOptions: {...}, // ACC_ 동적 드롭다운 옵션 정의 (선택)
};
```
### 엑셀 템플릿 구조
```
[시트: 안내] ← Config 기반 자동 생성 (컬럼 설명, 입력 규칙, 사용법)
[시트: 데이터1] ← 사용자가 작성하는 시트 (여러 개 가능)
[시트: 데이터2]
[숨김: 참조시트] ← VLOOKUP 참조 데이터 (referenceSheet 설정 시)
[숨김: _품목공정] ← INDIRECT 이름 범위 (itemProcessMappings 설정 시)
[숨김: _품목목록] ← 마스터 드롭다운 소스 (itemProcessMappings 설정 시)
[숨김: _합격기준옵션] ← INDIRECT ACC_ 이름 범위 (indirectOptions 설정 시)
[숨김: _meta] ← 버전 해시, 생성일
```
숨김시트들은 해당 기능을 사용하는 Config일 때만 생성된다.
### 버전 해시 검증
- 템플릿 생성 시: 참조 데이터 + 드롭다운 옵션 + 매핑 데이터 → 해시 생성 → `_meta` 시트에 저장
- 업로드 시: 현재 DB 데이터로 해시 재생성 → 일치 여부 확인
- 불일치 시: "기준 데이터가 변경되었습니다. 최신 템플릿을 다시 다운로드해주세요" 경고
### 안내시트 자동 생성
Config의 컬럼 정의를 기반으로 안내시트가 자동 생성된다.
- 컬럼별 설명 (필수 여부, 자동 입력 여부, 드롭다운 유형 등)
- 조건부 규칙 설명 (conditionalRules 기반)
- 잠금 셀 목록 (autoFill/readOnly/customFormula 컬럼)
- 사용 방법 단계
---
## 컬럼 타입
### 기본 타입
| type | 설명 |
|------|------|
| `text` | 자유 텍스트 |
| `number` | 숫자 (천단위 서식 자동 적용) |
| `date` | 날짜 |
| `dropdown` | 드롭다운 선택 |
### 드롭다운 source 유형
| source | 설명 | 예시 |
|--------|------|------|
| `custom` | 고정 값 목록 | `values: ["Y", "N"]` |
| `category` | 카테고리 테이블 조회 | `tableName: "...", columnName: "..."` |
| `indirect` | 다른 셀 값에 따라 동적 변경 | `indirectKeyColumn: "...", indirectPrefix: "P_"` |
### 컬럼 속성
| 속성 | 설명 |
|------|------|
| `required` | 필수 여부 — 업로드 검증 시 빈 값 체크 |
| `readOnly` | 읽기전용 — 셀 잠금 |
| `autoFill` | 참조시트에서 VLOOKUP 자동 입력 — 셀 잠금, 회색 배경 |
| `customFormula` | 커스텀 엑셀 수식 — `{col:key}` 플레이스홀더로 같은 행 참조 |
| `enableWhen` | 조건부 활성화 — 참조시트에서 직접 조회하여 판단 (VLOOKUP 미계산 문제 없음) |
| `disableWhen` | 조건부 비활성화 — 특정 조건일 때 입력 차단 |
| `width` | 컬럼 너비 (기본 18) |
---
## 주요 기능
### 1. VLOOKUP 자동 입력 (autoFill)
참조시트의 데이터를 기반으로 다른 셀 값에 연동되어 자동 입력된다.
```typescript
{
key: "detail_column",
label: "상세정보",
readOnly: true,
autoFill: {
lookupColumn: "master_key", // 같은 행의 이 컬럼 값을 기준으로
referenceColumn: "detail", // 참조시트에서 이 컬럼 값을 가져옴
}
}
```
### 2. 커스텀 수식 (customFormula)
`{col:key}` 플레이스홀더를 사용하여 같은 행의 다른 컬럼을 참조하는 수식을 정의한다. 절대참조(`$`)는 행 치환에서 자동 보호된다.
```typescript
{
key: "code_column",
readOnly: true,
customFormula: `IFERROR(INDEX('_시트명'!$A$1:$A$9999,MATCH({col:name_column},'_시트명'!$B$1:$B$9999,0)),"")`
}
```
### 3. INDIRECT 동적 드롭다운
다른 셀 값에 따라 드롭다운 옵션이 동적으로 변경된다.
**P_ prefix (이름 범위 직접 참조):**
```typescript
{
key: "sub_item",
type: "dropdown",
dropdown: {
source: "indirect",
indirectKeyColumn: "master_code", // 이 컬럼 값을 기준으로
indirectPrefix: "P_", // P_{값} 이름 범위 참조
}
}
```
**ACC_ prefix (MATCH 인덱스 기반 참조):**
```typescript
{
key: "criteria_value",
type: "dropdown",
dropdown: {
source: "indirect",
indirectKeyColumn: "standard_key",
indirectPrefix: "ACC_", // ACC_{인덱스} — MATCH로 인덱스 조회
}
}
```
ACC_ prefix 사용 시 Config에 `indirectOptions` 설정 필요:
```typescript
indirectOptions: {
conditionColumn: "condition_type", // 참조시트에서 조건 판단할 컬럼
optionsByCondition: { "타입A": ["O", "X"] }, // 조건값별 고정 옵션
selectionOptionsColumn: "options_column", // 동적 옵션 (콤마 구분 문자열)
}
```
### 4. 조건부 활성화/비활성화
다른 컬럼 값에 따라 셀 입력 가능 여부가 결정된다. 참조시트에서 직접 조회하는 방식이라 VLOOKUP 미계산 문제가 없다.
```typescript
{ key: "value_a", type: "number", enableWhen: { column: "condition_col", equals: "특정값" } }
```
### 5. 조건부 검증 규칙
업로드 시 특정 조건에 따라 필수/무시 컬럼이 달라진다. autoFill 컬럼의 값도 참조데이터에서 직접 조회하여 조건 판단.
```typescript
conditionalRules: [
{
when: { column: "condition_col", equals: "타입A" },
require: ["required_col"], // 필수
ignore: ["optional_col"], // 무시
},
]
```
### 6. ItemProcessMapping (마스터-디테일 매핑)
마스터 항목별로 선택 가능한 하위 항목이 다를 때 사용. 벌크 API로 전체 데이터를 한 번에 조회하여 INDIRECT 이름 범위로 등록.
```typescript
itemProcessMappings: [
{ itemCode: "M-001", itemName: "마스터A", processes: [{ code: "S01", name: "하위1" }] },
{ itemCode: "M-002", itemName: "마스터B", processes: [{ code: "S02", name: "하위2" }, { code: "S03", name: "하위3" }] },
]
```
업로드 검증 시 마스터에 맞지 않는 하위 항목은 자동으로 에러 처리된다.
---
## 성능 구조
| 항목 | 방식 | 범위 |
|------|------|------|
| 드롭다운/validation | **컬럼 범위 1회** 설정 | 65,000행 |
| 수식 (VLOOKUP, customFormula) | 행별 개별 삽입 | 2,000행 (FORMULA_END) |
| 셀 보호 (잠금/해제) | 행별 개별 설정 | 2,000행 |
| 셀 스타일 (배경, 테두리) | 행별 개별 설정 | 2,000행 |
| 데이터 캐싱 | 최초 로드 후 재사용 | 페이지 세션 |
드롭다운은 범위 단위라 행 수 제한 없음. 수식/스타일은 `FORMULA_END` 상수로 조절 가능.
---
## 사용법
### 1. 기본 사용 (단순 드롭다운만)
```tsx
import { SmartExcelUploadModal } from "@/components/common/SmartExcelUpload";
import type { SmartExcelUploadConfig, ParsedSheetData } from "@/components/common/SmartExcelUpload";
const config: SmartExcelUploadConfig = {
templateName: "거래처",
sheets: [{
name: "거래처",
columns: [
{ key: "name", label: "거래처명", required: true, type: "text", width: 24 },
{ key: "division", label: "구분", type: "dropdown",
dropdown: { source: "custom", values: ["매출처", "매입처"] } },
],
}],
};
const handleUpload = async (data: ParsedSheetData[]) => {
for (const sheet of data) {
for (const row of sheet.rows) await api.create(row);
}
};
<SmartExcelUploadModal
open={open}
onOpenChange={setOpen}
config={config}
dropdownOptions={{ division: ["매출처", "매입처"] }}
onUpload={handleUpload}
/>
```
### 2. 고급 사용 (참조시트 + INDIRECT + 조건부 검증)
```tsx
<SmartExcelUploadModal
open={open}
onOpenChange={setOpen}
config={config} // 시트/컬럼/규칙 정의
referenceData={refData} // 참조시트 데이터
dropdownOptions={dropdownOpts} // 드롭다운 옵션
itemProcessMappings={processMappings} // 마스터-디테일 매핑
labelToCodeMap={labelMap} // 라벨→코드 변환
onUpload={handleUpload} // 저장 콜백
dataLoading={loading} // 로딩 상태
loadProgress={{ loaded: 100, total: 500 }} // 진행률
/>
```
### 3. Props
| prop | 타입 | 필수 | 설명 |
|------|------|------|------|
| `open` | boolean | O | 모달 열림 상태 |
| `onOpenChange` | (open: boolean) => void | O | 모달 상태 변경 |
| `config` | SmartExcelUploadConfig | O | 전체 설정 |
| `referenceData` | Record[] | | 참조시트 데이터 |
| `dropdownOptions` | Record<string, string[]> | | 드롭다운 옵션 (키: `시트명:컬럼key` 또는 `컬럼key`) |
| `itemProcessMappings` | ItemProcessMapping[] | | 마스터-디테일 매핑 데이터 |
| `labelToCodeMap` | Record<string, Record<string, string>> | | 라벨→코드 변환 |
| `extraMeta` | Record<string, string> | | _meta 시트에 추가할 정보 |
| `onUpload` | (data: ParsedSheetData[]) => Promise<void> | O | 업로드 완료 콜백 |
| `subtitle` | string | | 제목 아래 부가 설명 |
| `dataLoading` | boolean | | 외부 데이터 로딩 중 표시 |
| `loadProgress` | { loaded, total } | | 로딩 진행률 표시 |
### 4. 벌크 조회 API (백엔드)
마스터별 하위 항목을 한 번에 조회하는 API. 5,000건 단위 청크 분할로 대량 데이터 대응.
```
POST /work-instruction/routing-versions-bulk
Body: { itemCodes: ["M-001", "M-002", ...] }
Response: { success: true, data: { "M-001": [{ code, name }], "M-002": [...] } }
```
---
## 검증 흐름
```
업로드 → 메타 해시 검증 → 시트별 파싱 (사용자 입력 컬럼만 빈 행 체크)
→ 필수값 검증 (conditionalRules 적용, autoFill 값은 참조데이터에서 직접 조회)
→ 드롭다운 유효성 검증
→ INDIRECT 매핑 검증 (마스터에 맞지 않는 하위 항목 에러)
→ 에러 있으면 에러 리포트 / 없으면 미리보기 → 저장
```
---
## 확장 시 참고
- 새 화면에 적용할 때: Config 정의 + 데이터 조회 + 저장 콜백만 작성
- 단일 시트 / 멀티 시트 모두 지원 (`sheets` 배열 크기로 결정)
- 참조시트 필요 없으면 `referenceSheet` 생략 → 숨김시트 미생성
- 조건부 검증 필요 없으면 `conditionalRules` 생략 → 단순 필수값 체크만
- INDIRECT 필요 없으면 `itemProcessMappings` 생략 → 일반 드롭다운만
- ACC_ 동적 드롭다운 필요 없으면 `indirectOptions` 생략 → 해당 시트 미생성
- `FORMULA_END` (기본 2,000) / `VALIDATION_END` (기본 65,000)으로 범위 조절 가능

View File

@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { cn } from "@/lib/utils";
@@ -22,6 +22,8 @@ import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { SmartExcelUploadModal } from "@/components/common/SmartExcelUpload";
import type { SmartExcelUploadConfig, ParsedSheetData } from "@/components/common/SmartExcelUpload";
const TABLE_NAME = "item_inspection_info";
const ITEM_TABLE = "item_info";
@@ -96,6 +98,9 @@ export default function ItemInspectionInfoPage() {
// 기본 라우팅 공정 목록 (적용공정 Select용)
const [processOptions, setProcessOptions] = useState<{ code: string; name: string }[]>([]);
// 엑셀 업로드 모달
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 품목 선택 모달
const [itemModalOpen, setItemModalOpen] = useState(false);
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
@@ -470,6 +475,227 @@ export default function ItemInspectionInfoPage() {
} catch { toast.error("삭제에 실패했어요"); }
};
/* ═══════════════════ 엑셀 업로드 (다건 품목 모드) ═══════════════════ */
const [excelItemProcessMappings, setExcelItemProcessMappings] = useState<import("@/components/common/SmartExcelUpload").ItemProcessMapping[]>([]);
const [excelLoading, setExcelLoading] = useState(false);
const [excelLoadProgress, setExcelLoadProgress] = useState({ loaded: 0, total: 0 });
const openExcelUpload = async () => {
setExcelUploadOpen(true);
// 캐시 히트: 이미 로드된 데이터 있으면 재사용
if (excelItemProcessMappings.length > 0) return;
setExcelLoading(true);
setExcelLoadProgress({ loaded: 0, total: 0 });
try {
// 1. 전체 품목 조회
const itemRes = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 99999, autoFilter: true,
});
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
setExcelLoadProgress({ loaded: items.length / 2, total: items.length });
// 2. 벌크 라우팅 조회 (1회 API 호출)
const itemCodes = items.map((item: any) => item.item_number || item.item_code || "").filter(Boolean);
let processMap: Record<string, { code: string; name: string }[]> = {};
try {
const bulkRes = await apiClient.post(`/work-instruction/routing-versions-bulk`, { itemCodes });
if (bulkRes.data?.success) {
processMap = bulkRes.data.data || {};
}
} catch { /* 벌크 API 실패 시 빈 공정으로 진행 */ }
// 3. 매핑 구성
const mappings: import("@/components/common/SmartExcelUpload").ItemProcessMapping[] = items.map((item: any) => {
const code = item.item_number || item.item_code || "";
return {
itemCode: code,
itemName: item.item_name || "",
processes: processMap[code] || [],
};
});
setExcelLoadProgress({ loaded: items.length, total: items.length });
setExcelItemProcessMappings(mappings);
toast.success(`${mappings.length}개 품목 로드 완료`);
} catch {
toast.error("품목 정보 로드에 실패했습니다");
} finally {
setExcelLoading(false);
}
};
// 엑셀 Config 생성 (다건 품목 모드)
const excelUploadConfig = useMemo((): SmartExcelUploadConfig => {
const itemCount = excelItemProcessMappings.length || 9999;
const makeColumns = () => [
{ key: "item_name", label: "품목명", required: true, type: "dropdown" as const, dropdown: { source: "custom" as const, values: [] }, width: 22 },
{ key: "item_code", label: "품목코드", type: "text" as const, readOnly: true, customFormula: `IFERROR(INDEX('_품목목록'!$A$1:$A$${itemCount},MATCH({col:item_name},'_품목목록'!$B$1:$B$${itemCount},0)),"")`, width: 16 },
{ key: "inspection_standard", label: "검사기준", required: true, type: "dropdown" as const, dropdown: { source: "custom" as const, values: [] }, width: 22 },
{ key: "inspection_detail", label: "검사기준 상세", type: "text" as const, readOnly: true, autoFill: { lookupColumn: "inspection_standard", referenceColumn: "detail" }, width: 20 },
{ key: "inspection_method", label: "검사방법", type: "text" as const, readOnly: true, autoFill: { lookupColumn: "inspection_standard", referenceColumn: "method" }, width: 14 },
{ key: "apply_process", label: "적용공정", type: "dropdown" as const, dropdown: { source: "indirect" as const, indirectKeyColumn: "item_code" }, width: 14 },
{ key: "judgment_criteria", label: "판단기준", type: "text" as const, readOnly: true, autoFill: { lookupColumn: "inspection_standard", referenceColumn: "judgment_criteria" }, width: 14 },
{ key: "standard_value", label: "기준값", type: "number" as const, enableWhen: { column: "judgment_criteria", equals: "수치(범위)" }, width: 12 },
{ key: "tolerance", label: "오차", type: "number" as const, enableWhen: { column: "judgment_criteria", equals: "수치(범위)" }, width: 10 },
{ key: "unit", label: "단위", type: "text" as const, readOnly: true, autoFill: { lookupColumn: "inspection_standard", referenceColumn: "unit" }, width: 10 },
{ key: "acceptance_criteria", label: "합격기준", type: "dropdown" as const, dropdown: { source: "indirect" as const, indirectKeyColumn: "inspection_standard", indirectPrefix: "ACC_" }, width: 18 },
{ key: "is_required", label: "필수", type: "dropdown" as const, dropdown: { source: "custom" as const, values: ["Y", "N"] }, width: 8 },
];
return {
templateName: "품목검사정보",
sheets: INSPECTION_TYPES.map(t => ({
name: t.label,
typeKey: t.label,
columns: makeColumns(),
})),
referenceSheet: {
name: "검사기준정보",
columns: [
{ key: "label", label: "검사기준명" },
{ key: "detail", label: "검사기준 상세" },
{ key: "method", label: "검사방법" },
{ key: "judgment_criteria", label: "판단기준" },
{ key: "selection_options", label: "선택옵션" },
{ key: "unit", label: "단위" },
{ key: "types", label: "검사유형" },
],
},
conditionalRules: [
{ when: { column: "judgment_criteria", equals: "수치(범위)" }, require: ["standard_value"], ignore: ["acceptance_criteria"] },
{ when: { column: "judgment_criteria", equals: "O/X" }, require: ["acceptance_criteria"], ignore: ["standard_value", "tolerance"] },
{ when: { column: "judgment_criteria", equals: "선택형" }, require: ["acceptance_criteria"], ignore: ["standard_value", "tolerance"] },
{ when: { column: "judgment_criteria", equals: "텍스트입력" }, require: ["acceptance_criteria"], ignore: ["standard_value", "tolerance"] },
],
indirectOptions: {
conditionColumn: "judgment_criteria",
optionsByCondition: { "O/X": ["O", "X"] },
selectionOptionsColumn: "selection_options",
},
};
}, [excelItemProcessMappings]);
// 참조 데이터 구성
const excelReferenceData = useMemo(() => {
return inspOptions.map(opt => {
const methodLabel = inspMethodCatOptions.find(o => o.code === opt.method)?.label || opt.method;
const jcLabel = judgmentCatOptions.find(c => c.code === opt.judgment_criteria)?.label || opt.judgment_criteria;
const unitLabel = inspUnitCatOptions.find(c => c.code === opt.unit)?.label || opt.unit;
const typeLabels = opt.types.map(t => inspTypeCatOptions.find(c => c.code === t)?.label || t).join(",");
return { label: opt.label, detail: opt.detail, method: methodLabel, judgment_criteria: jcLabel, selection_options: opt.selection_options, unit: unitLabel, types: typeLabels };
});
}, [inspOptions, inspMethodCatOptions, judgmentCatOptions, inspUnitCatOptions, inspTypeCatOptions]);
// 시트별 드롭다운 옵션
const excelDropdownOptions = useMemo(() => {
const opts: Record<string, string[]> = {};
for (const t of INSPECTION_TYPES) {
const matchCodes = inspTypeCatOptions.filter(cat => t.matchLabels.some(ml => cat.label.includes(ml))).map(cat => cat.code);
const filtered = matchCodes.length > 0
? inspOptions.filter(opt => opt.types.some(tp => matchCodes.includes(tp)))
: inspOptions;
opts[`${t.label}:inspection_standard`] = filtered.map(o => o.label);
}
opts["is_required"] = ["Y", "N"];
// 품목명 드롭다운
opts["item_name"] = excelItemProcessMappings.map(m => m.itemName);
return opts;
}, [inspOptions, inspTypeCatOptions, excelItemProcessMappings]);
// 라벨→코드 매핑
const excelLabelToCodeMap = useMemo(() => {
const map: Record<string, Record<string, string>> = {};
map["inspection_standard"] = {};
for (const opt of inspOptions) map["inspection_standard"][opt.label] = opt.code;
// 품목명→품목코드
map["item_name"] = {};
for (const m of excelItemProcessMappings) map["item_name"][m.itemName] = m.itemCode;
// 적용공정 이름→코드 (전체 품목 공정에서)
map["apply_process"] = {};
for (const m of excelItemProcessMappings) {
for (const p of m.processes) map["apply_process"][p.name] = p.code;
}
return map;
}, [inspOptions, excelItemProcessMappings]);
// 엑셀 업로드 저장 (다건)
const handleExcelUpload = async (data: ParsedSheetData[]) => {
// 품목코드별로 그룹핑
const itemCodeSet = new Set<string>();
const rows: any[] = [];
for (const sheet of data) {
for (const row of sheet.rows) {
// 품목코드: 수식 결과 또는 품목명으로 역매핑
let itemCode = row.item_code || "";
const itemName = row.item_name || "";
if (!itemCode && itemName) {
const mapping = excelItemProcessMappings.find(m => m.itemName === itemName);
if (mapping) itemCode = mapping.itemCode;
}
if (!itemCode) continue;
itemCodeSet.add(itemCode);
const inspLabel = row.inspection_standard || "";
const inspId = excelLabelToCodeMap["inspection_standard"]?.[inspLabel] || inspLabel;
const inspOpt = inspOptions.find(o => o.code === inspId);
const itemMapping = excelItemProcessMappings.find(m => m.itemCode === itemCode);
let passCriteria = "";
const jcLabel = inspOpt ? (judgmentCatOptions.find(c => c.code === inspOpt.judgment_criteria)?.label || inspOpt.judgment_criteria) : "";
if (jcLabel === "수치(범위)") {
passCriteria = `${row.standard_value || ""}|${row.tolerance || ""}`;
} else {
passCriteria = row.acceptance_criteria || "";
}
// 적용공정 검증: 해당 품목의 유효 공정인지 확인 (품목 변경 후 공정 미초기화 대응)
let applyProcess = row.apply_process || "";
if (applyProcess && itemMapping) {
const validProcess = itemMapping.processes.find(p => p.code === applyProcess || p.name === applyProcess);
if (!validProcess) {
applyProcess = ""; // 유효하지 않은 공정은 비움
}
}
rows.push({
id: crypto.randomUUID(),
item_code: itemCode,
item_name: itemMapping?.itemName || itemCode,
inspection_type: sheet.typeKey || sheet.sheetName,
inspection_standard_id: inspId,
inspection_item_name: inspOpt?.detail || row.inspection_detail || "",
inspection_method: inspOpt?.method || "",
apply_process: applyProcess,
pass_criteria: passCriteria,
is_required: row.is_required === "Y" ? "true" : "false",
is_active: "사용",
});
}
}
// 해당 품목들의 기존 데이터 삭제 후 재등록
for (const itemCode of itemCodeSet) {
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 9999,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: itemCode }] },
autoFilter: true,
});
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
}
}
for (const row of rows) {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row);
}
fetchData();
};
/* ═══════════════════ JSX ═══════════════════ */
return (
<div className="flex h-full flex-col">
@@ -500,6 +726,9 @@ export default function ItemInspectionInfoPage() {
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" className="h-7 text-xs" onClick={openCreate}><Plus className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openExcelUpload}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
@@ -655,7 +884,18 @@ export default function ItemInspectionInfoPage() {
<TableCell className="text-xs py-2">{row.inspection_item_name || "-"}</TableCell>
<TableCell className="text-xs py-2">{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
<TableCell className="text-xs py-2">{(() => {
const code = row.apply_process;
if (!code) return "-";
// excelItemProcessMappings에서 공정명 찾기
for (const m of excelItemProcessMappings) {
const proc = m.processes.find(p => p.code === code);
if (proc) return proc.name;
}
// processOptions (모달용)에서 찾기
const proc = processOptions.find(p => p.code === code);
return proc?.name || code;
})()}</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
@@ -930,6 +1170,21 @@ export default function ItemInspectionInfoPage() {
</Dialog>
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
{/* ═══════ 엑셀 업로드 모달 ═══════ */}
<SmartExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
config={excelUploadConfig}
referenceData={excelReferenceData}
dropdownOptions={excelDropdownOptions}
itemProcessMappings={excelItemProcessMappings}
labelToCodeMap={excelLabelToCodeMap}
onUpload={handleExcelUpload}
subtitle={`전체 ${excelItemProcessMappings.length}개 품목`}
dataLoading={excelLoading}
loadProgress={excelLoadProgress}
/>
</div>
);
}

View File

@@ -0,0 +1,589 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Download,
Upload,
FileSpreadsheet,
AlertCircle,
CheckCircle2,
XCircle,
Loader2,
ArrowRight,
Info,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import type {
SmartExcelUploadConfig,
ParseResult,
ParsedSheetData,
ValidationError,
ItemProcessMapping,
} from "./types";
import { generateTemplate } from "./templateGenerator";
import type { GenerateTemplateOptions } from "./templateGenerator";
import { parseTemplate } from "./templateParser";
import type { ParseOptions } from "./templateParser";
export interface SmartExcelUploadModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
config: SmartExcelUploadConfig;
/** 참조 데이터 (DB에서 조회한 검사기준 등) */
referenceData?: Record<string, any>[];
/** 시트별/컬럼별 드롭다운 옵션 */
dropdownOptions?: Record<string, string[]>;
/** 품목별 공정 매핑 (INDIRECT 동적 드롭다운용) */
itemProcessMappings?: ItemProcessMapping[];
/** 라벨→코드 변환 매핑 */
labelToCodeMap?: Record<string, Record<string, string>>;
/** 추가 메타 정보 (item_code 등) */
extraMeta?: Record<string, string>;
/** 업로드 완료 콜백 */
onUpload: (data: ParsedSheetData[]) => Promise<void>;
/** 품목명 등 표시용 제목 */
subtitle?: string;
/** 데이터 로딩 중 여부 (외부에서 품목 등 로딩 시) */
dataLoading?: boolean;
/** 로딩 진행률 */
loadProgress?: { loaded: number; total: number };
}
type Step = "download" | "upload" | "validate" | "preview";
export function SmartExcelUploadModal({
open,
onOpenChange,
config,
referenceData = [],
dropdownOptions = {},
itemProcessMappings = [],
labelToCodeMap = {},
extraMeta = {},
onUpload,
subtitle,
dataLoading = false,
loadProgress,
}: SmartExcelUploadModalProps) {
const [step, setStep] = useState<Step>("download");
const [downloading, setDownloading] = useState(false);
const [templateDownloaded, setTemplateDownloaded] = useState(false);
const [uploading, setUploading] = useState(false);
const [saving, setSaving] = useState(false);
const [parseResult, setParseResult] = useState<ParseResult | null>(null);
const [previewTab, setPreviewTab] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const reset = useCallback(() => {
setStep("download");
setTemplateDownloaded(false);
setParseResult(null);
setPreviewTab(0);
if (fileInputRef.current) fileInputRef.current.value = "";
}, []);
const handleClose = (open: boolean) => {
if (!open) reset();
onOpenChange(open);
};
// ═══════════════════ 템플릿 다운로드 ═══════════════════
const handleDownload = async () => {
setDownloading(true);
try {
const options: GenerateTemplateOptions = {
config,
referenceData,
dropdownOptions,
itemProcessMappings: itemProcessMappings.length > 0 ? itemProcessMappings : undefined,
extraMeta,
};
const buffer = await generateTemplate(options);
const blob = new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${config.templateName}_템플릿.xlsx`;
a.click();
URL.revokeObjectURL(url);
setTemplateDownloaded(true);
toast.success("템플릿 다운로드 완료");
} catch (err) {
console.error("템플릿 생성 실패:", err);
toast.error("템플릿 생성에 실패했습니다");
} finally {
setDownloading(false);
}
};
// ═══════════════════ 파일 업로드 + 파싱 ═══════════════════
const handleFileChange = async (
e: React.ChangeEvent<HTMLInputElement>
) => {
const file = e.target.files?.[0];
if (!file) return;
// 같은 파일 재선택 허용
e.target.value = "";
if (!file.name.endsWith(".xlsx") && !file.name.endsWith(".xls")) {
toast.error("엑셀 파일(.xlsx)만 업로드 가능합니다");
return;
}
setUploading(true);
setStep("validate");
setPreviewTab(0);
try {
const parseOptions: ParseOptions = {
config,
file,
currentReferenceData: referenceData,
currentDropdownOptions: dropdownOptions,
currentItemProcessMappings: itemProcessMappings.length > 0 ? itemProcessMappings : undefined,
labelToCodeMap,
};
const result = await parseTemplate(parseOptions);
setParseResult(result);
if (result.success) {
setStep("preview");
toast.success("검증 통과! 미리보기를 확인해주세요");
} else if (result.warnings.length > 0 && result.errors.length === 0) {
// 경고만 있는 경우
setStep("validate");
} else {
setStep("validate");
toast.error(`검증 실패: ${result.errors.length}건의 오류`);
}
} catch (err) {
console.error("파싱 실패:", err);
toast.error("파일 파싱에 실패했습니다");
setStep("upload");
} finally {
setUploading(false);
}
};
// ═══════════════════ 저장 ═══════════════════
const handleSave = async () => {
if (!parseResult?.data?.length) return;
setSaving(true);
try {
await onUpload(parseResult.data);
toast.success("업로드 완료");
handleClose(false);
} catch (err) {
console.error("저장 실패:", err);
toast.error("저장에 실패했습니다");
} finally {
setSaving(false);
}
};
// ═══════════════════ 렌더링 ═══════════════════
const totalRows =
parseResult?.data?.reduce((sum, d) => sum + d.rows.length, 0) || 0;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[800px] max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5" />
{config.templateName}
</DialogTitle>
<DialogDescription>
{subtitle || "템플릿을 다운로드하여 데이터를 작성한 후 업로드해주세요"}
</DialogDescription>
</DialogHeader>
{/* 스텝 인디케이터 */}
<div className="flex items-center gap-2 px-1 py-2">
{(
[
{ key: "download", label: "템플릿 다운로드", icon: Download },
{ key: "upload", label: "파일 업로드", icon: Upload },
{ key: "validate", label: "검증", icon: AlertCircle },
{ key: "preview", label: "미리보기", icon: CheckCircle2 },
] as const
).map(({ key, label, icon: Icon }, i) => {
const stepOrder = ["download", "upload", "validate", "preview"];
const currentIdx = stepOrder.indexOf(step);
const thisIdx = stepOrder.indexOf(key);
const isActive = key === step;
const isDone = thisIdx < currentIdx;
return (
<React.Fragment key={key}>
{i > 0 && (
<ArrowRight
className={cn(
"h-3 w-3 shrink-0",
isDone
? "text-primary"
: "text-muted-foreground/40"
)}
/>
)}
<div
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: isDone
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground"
)}
>
<Icon className="h-3 w-3" />
{label}
</div>
</React.Fragment>
);
})}
</div>
{/* 컨텐츠 영역 */}
<div className="flex-1 min-h-0 overflow-auto space-y-4">
{/* ── 다운로드 단계 ── */}
{(step === "download" || step === "upload") && (
<div className="space-y-4">
{/* 데이터 로딩 진행률 */}
{dataLoading && loadProgress && loadProgress.total > 0 && (
<div className="border rounded-lg p-4 space-y-2 bg-blue-50/50 border-blue-200">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
<span className="text-sm font-medium text-blue-700">
...
</span>
<span className="text-xs text-blue-600 ml-auto">
{loadProgress.loaded.toLocaleString()} / {loadProgress.total.toLocaleString()}
</span>
</div>
<div className="w-full bg-blue-100 rounded-full h-1.5">
<div
className="bg-blue-600 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${Math.round((loadProgress.loaded / loadProgress.total) * 100)}%` }}
/>
</div>
</div>
)}
{/* 템플릿 다운로드 */}
<div
className={cn(
"border rounded-lg p-4 space-y-3",
templateDownloaded
? "border-primary/30 bg-primary/5"
: "border-dashed",
dataLoading && "opacity-50 pointer-events-none"
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Download
className={cn(
"h-4 w-4",
templateDownloaded
? "text-primary"
: "text-muted-foreground"
)}
/>
<span className="text-sm font-medium">
1. 릿
</span>
{templateDownloaded && (
<Badge
variant="default"
className="text-[10px] h-5"
>
</Badge>
)}
</div>
<Button
size="sm"
variant={templateDownloaded ? "outline" : "default"}
onClick={handleDownload}
disabled={downloading || dataLoading}
className="h-8"
>
{downloading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
) : (
<Download className="h-3.5 w-3.5 mr-1" />
)}
{templateDownloaded ? "다시 다운로드" : "템플릿 다운로드"}
</Button>
</div>
<div className="text-xs text-muted-foreground space-y-1">
<p>
:{" "}
{config.sheets.map((s) => s.name).join(", ")}
</p>
{config.referenceSheet && (
<p> : {referenceData.length} </p>
)}
</div>
</div>
{/* 파일 업로드 */}
<div
className={cn(
"border rounded-lg p-4 space-y-3",
dataLoading && "opacity-50 pointer-events-none"
)}
>
<div className="flex items-center gap-2">
<Upload className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
2.
</span>
</div>
<div className="flex items-center gap-3">
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls"
onChange={handleFileChange}
className="text-sm file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-medium file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 cursor-pointer"
disabled={dataLoading || uploading}
/>
{uploading && (
<Loader2 className="h-4 w-4 animate-spin text-primary" />
)}
</div>
{dataLoading && (
<p className="text-xs text-amber-600 flex items-center gap-1">
<Info className="h-3 w-3" />
.
</p>
)}
</div>
</div>
)}
{/* ── 검증 결과 (에러) ── */}
{step === "validate" && parseResult && (
<div className="space-y-4">
{/* 경고 메시지 */}
{parseResult.warnings.map((w, i) => (
<div
key={i}
className="flex items-start gap-2 p-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-800"
>
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<span className="text-sm">{w}</span>
</div>
))}
{/* 에러 목록 */}
{parseResult.errors.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<XCircle className="h-4 w-4 text-destructive" />
<span className="text-sm font-medium text-destructive">
{parseResult.errors.length}
</span>
</div>
<div className="border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-[10px] font-bold w-[100px]">
</TableHead>
<TableHead className="text-[10px] font-bold w-[60px]">
</TableHead>
<TableHead className="text-[10px] font-bold w-[100px]">
</TableHead>
<TableHead className="text-[10px] font-bold">
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{parseResult.errors.map((err, i) => (
<TableRow key={i}>
<TableCell className="text-xs py-1.5">
<Badge
variant="outline"
className="text-[10px]"
>
{err.sheet}
</Badge>
</TableCell>
<TableCell className="text-xs py-1.5 font-mono">
{err.row}
</TableCell>
<TableCell className="text-xs py-1.5">
{err.column}
</TableCell>
<TableCell className="text-xs py-1.5 text-destructive">
{err.message}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setStep("upload");
setParseResult(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}}
>
</Button>
</div>
</div>
)}
{/* ── 미리보기 ── */}
{step === "preview" && parseResult?.data && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium text-green-700">
</span>
<Badge variant="secondary" className="text-[10px]">
{totalRows}
</Badge>
</div>
{/* 시트 탭 */}
{parseResult.data.length > 1 && (
<div className="flex gap-1.5">
{parseResult.data.map((sheet, i) => (
<button
key={i}
type="button"
onClick={() => setPreviewTab(i)}
className={cn(
"px-3 py-1.5 rounded-full text-xs font-medium transition-colors border",
previewTab === i
? "bg-primary text-primary-foreground border-primary"
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
)}
>
{sheet.sheetName}
<span className="ml-1 opacity-70">
({sheet.rows.length})
</span>
</button>
))}
</div>
)}
{/* 데이터 테이블 */}
{parseResult.data[previewTab] && (
<div className="border rounded-lg overflow-hidden max-h-[350px] overflow-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-[10px] font-bold w-[40px]">
#
</TableHead>
{(() => {
const sheetData = parseResult.data[previewTab];
const sheetConfig = config.sheets.find(
(s) => s.name === sheetData.sheetName
);
return (sheetConfig?.columns || [])
.filter((c) => !c.readOnly && !c.autoFill && !c.customFormula)
.map((col) => (
<TableHead
key={col.key}
className="text-[10px] font-bold"
>
{col.label}
</TableHead>
));
})()}
</TableRow>
</TableHeader>
<TableBody>
{parseResult.data[previewTab].rows.map((row, i) => {
const sheetData = parseResult.data[previewTab];
const sheetConfig = config.sheets.find(
(s) => s.name === sheetData.sheetName
);
return (
<TableRow key={i}>
<TableCell className="text-[10px] py-1.5 text-muted-foreground">
{i + 1}
</TableCell>
{(sheetConfig?.columns || [])
.filter((c) => !c.readOnly && !c.autoFill && !c.customFormula)
.map((col) => (
<TableCell
key={col.key}
className="text-xs py-1.5"
>
{row[`${col.key}_label`] ||
row[col.key] ||
"-"}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
)}
</div>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => handleClose(false)}>
</Button>
{step === "preview" && (
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin mr-1" />
) : (
<Upload className="h-4 w-4 mr-1" />
)}
{totalRows}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,16 @@
export { SmartExcelUploadModal } from "./SmartExcelUploadModal";
export type {
SmartExcelUploadConfig,
SheetConfig,
SmartColumn,
ReferenceSheetConfig,
ConditionalRule,
DropdownConfig,
ItemProcessMapping,
IndirectOptionsConfig,
ParsedSheetData,
ParseResult,
ValidationError,
} from "./types";
export { generateTemplate, regenerateHash } from "./templateGenerator";
export { parseTemplate } from "./templateParser";

View File

@@ -0,0 +1,528 @@
/**
* SmartExcelUpload — 템플릿 생성기
* ExcelJS 기반: 드롭다운, VLOOKUP 수식, INDIRECT 동적 드롭다운,
* 참조시트, 조건부서식, _meta 해시시트
*/
import ExcelJS from "exceljs";
import type {
SmartExcelUploadConfig,
ItemProcessMapping,
} from "./types";
/** 카테고리 값들로 해시 생성 */
function generateHash(data: string): string {
let hash = 0;
for (let i = 0; i < data.length; i++) {
const char = data.charCodeAt(i);
hash = ((hash << 5) - hash + char) | 0;
}
return Math.abs(hash).toString(36);
}
/** 엑셀 컬럼 문자 (1→A, 2→B, ..., 27→AA) */
function colLetter(index: number): string {
let result = "";
let n = index;
while (n > 0) {
n--;
result = String.fromCharCode(65 + (n % 26)) + result;
n = Math.floor(n / 26);
}
return result;
}
/** 품목코드를 엑셀 이름 범위에 사용 가능한 이름으로 변환 */
function sanitizeNameForExcel(itemCode: string): string {
// 엑셀 이름 범위 규칙: 문자/밑줄로 시작, 영숫자/밑줄만 허용
return "P_" + itemCode.replace(/[^a-zA-Z0-9]/g, "_");
}
export interface GenerateTemplateOptions {
config: SmartExcelUploadConfig;
/** 참조시트 데이터 (DB에서 조회한 검사기준 등) */
referenceData?: Record<string, any>[];
/** 시트별 드롭다운 옵션 (카테고리 값 등) */
dropdownOptions?: Record<string, string[]>;
/** 품목별 공정 매핑 (INDIRECT 동적 드롭다운용) */
itemProcessMappings?: ItemProcessMapping[];
/** 추가 메타 정보 */
extraMeta?: Record<string, string>;
}
export async function generateTemplate(
options: GenerateTemplateOptions
): Promise<ExcelJS.Buffer> {
const { config, referenceData, dropdownOptions, itemProcessMappings, extraMeta } = options;
const workbook = new ExcelJS.Workbook();
workbook.creator = "WACE ERP";
workbook.created = new Date();
// 해시 소스: 참조 데이터 + 드롭다운 옵션 + 품목공정매핑
const hashSource = JSON.stringify({
referenceData,
dropdownOptions,
itemProcessMappings: itemProcessMappings?.map(m => ({
itemCode: m.itemCode,
processes: m.processes.map(p => p.name),
})),
});
const versionHash = generateHash(hashSource);
// ═══════════════════ 1. 참조시트 생성 ═══════════════════
let refSheet: ExcelJS.Worksheet | null = null;
const refDataRows = referenceData || config.referenceSheet?.data || [];
const refCols = config.referenceSheet?.columns || [];
if (config.referenceSheet && refCols.length > 0) {
refSheet = workbook.addWorksheet(config.referenceSheet.name, {
state: "veryHidden",
});
// 헤더
const headerRow = refSheet.addRow(refCols.map((c) => c.label));
headerRow.eachCell((cell) => {
cell.font = { bold: true, size: 10 };
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFE2E8F0" } };
cell.border = { bottom: { style: "thin", color: { argb: "FF94A3B8" } } };
});
for (const row of refDataRows) {
refSheet.addRow(refCols.map((c) => row[c.key] ?? ""));
}
refCols.forEach((_, i) => { refSheet!.getColumn(i + 1).width = 20; });
}
// ═══════════════════ 1-B. 합격기준 옵션 시트 (INDIRECT용) ═══════════════════
// 검사기준별 판단기준에 따라 합격기준 드롭다운을 동적으로 변경
// 한글은 엑셀 이름 범위에 사용 불가 → 숫자 인덱스 ACC_1, ACC_2... 사용
// INDIRECT("ACC_" & MATCH(검사기준셀, 검사기준정보!A열, 0)) 방식
// ═══════════════════ 1-B. INDIRECT ACC_ 동적 드롭다운 옵션 시트 ═══════════════════
const hasAccPrefix = config.sheets.some(s => s.columns.some(c => c.dropdown?.indirectPrefix === "ACC_"));
if (refDataRows.length > 0 && hasAccPrefix && config.indirectOptions) {
const accSheet = workbook.addWorksheet("_합격기준옵션", { state: "veryHidden" });
const { conditionColumn, optionsByCondition, selectionOptionsColumn } = config.indirectOptions;
for (let i = 0; i < refDataRows.length; i++) {
const row = refDataRows[i];
const condValue = row[conditionColumn] || "";
const rowNum = i + 1;
const safeName = `ACC_${rowNum}`;
// 조건값에 매칭되는 고정 옵션 확인
let options: string[] = optionsByCondition[condValue] || [];
// 선택옵션 컬럼에서 동적으로 가져오기 (콤마 구분)
if (options.length === 0 && selectionOptionsColumn) {
const selOpts = row[selectionOptionsColumn] || "";
if (selOpts) {
options = selOpts.split(",").map((s: string) => s.trim()).filter(Boolean);
}
}
if (options.length > 0) {
for (let j = 0; j < options.length; j++) {
accSheet.getCell(`${colLetter(j + 1)}${rowNum}`).value = options[j];
}
const endCol = colLetter(options.length);
try {
workbook.definedNames.add(`'_합격기준옵션'!$A$${rowNum}:$${endCol}$${rowNum}`, safeName);
} catch { /* skip */ }
}
// 매칭 안 되면 이름 범위 미등록 → INDIRECT 실패 → 자유 입력
}
}
// ═══════════════════ 2. 품목공정 매핑 시트 (INDIRECT용) ═══════════════════
const hasItemProcess = itemProcessMappings && itemProcessMappings.length > 0;
if (hasItemProcess) {
const procSheet = workbook.addWorksheet("_품목공정", { state: "veryHidden" });
procSheet.getColumn(1).width = 20;
// 품목별로 열에 공정 목록 배치, 이름 범위 등록
// 방식: 각 품목코드를 열 방향으로 배치 (A열: 품목1 공정들, B열: 품목2 공정들...)
// → 이름 범위 수가 너무 많아질 수 있으므로, 행 방향으로 배치
// 방식 변경: 품목코드를 A열, 공정을 B열~에 넣고, 이름 범위로 등록
// → ExcelJS는 definedNames 지원
for (let i = 0; i < itemProcessMappings!.length; i++) {
const mapping = itemProcessMappings![i];
const safeName = sanitizeNameForExcel(mapping.itemCode);
const rowNum = i + 1;
// A열: 품목코드 (참조용)
procSheet.getCell(`A${rowNum}`).value = mapping.itemCode;
// B열~: 공정명들
const procs = mapping.processes;
if (procs.length > 0) {
for (let j = 0; j < procs.length; j++) {
procSheet.getCell(`${colLetter(j + 2)}${rowNum}`).value = procs[j].name;
}
// 이름 범위 등록: add(rangeString, name)
const startCol = colLetter(2); // B
const endCol = colLetter(procs.length + 1);
workbook.definedNames.add(`'_품목공정'!$${startCol}$${rowNum}:$${endCol}$${rowNum}`, safeName);
}
// 공정 없는 품목: 이름 범위 미등록 → INDIRECT 실패 → 드롭다운 안 뜸 (자유 입력)
}
// 품목코드 목록도 별도 시트에 (드롭다운용)
const itemListSheet = workbook.addWorksheet("_품목목록", { state: "veryHidden" });
itemProcessMappings!.forEach((m, i) => {
itemListSheet.getCell(`A${i + 1}`).value = m.itemCode;
itemListSheet.getCell(`B${i + 1}`).value = m.itemName;
});
}
// ═══════════════════ 3. 검사시트별 생성 ═══════════════════
for (const sheetConfig of config.sheets) {
const ws = workbook.addWorksheet(sheetConfig.name);
// 컬럼 너비
sheetConfig.columns.forEach((col, i) => {
ws.getColumn(i + 1).width = col.width || 18;
});
// 헤더 행
const headerRow = ws.addRow(sheetConfig.columns.map((c) => c.label));
headerRow.height = 28;
headerRow.eachCell((cell, colNumber) => {
const col = sheetConfig.columns[colNumber - 1];
cell.font = { bold: true, size: 10, color: { argb: "FF1E293B" } };
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFF1F5F9" } };
cell.border = { bottom: { style: "medium", color: { argb: "FF94A3B8" } } };
cell.alignment = { vertical: "middle", horizontal: "center" };
if (col?.required) {
cell.font = { bold: true, size: 10, color: { argb: "FFDC2626" } };
}
if (col?.readOnly || col?.autoFill) {
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFE2E8F0" } };
}
});
// ═══════ 컬럼 범위 기반 data validation + 수식 (행 제한 없음) ═══════
// 행 2부터 10000까지 범위 설정 (셀 루프 대신 범위 단위)
// 수식 행 수: 성능과 사용성 균형 (드롭다운 validation은 범위라 무제한)
const FORMULA_END = 2000;
const VALIDATION_END = 65000;
for (let colIdx = 0; colIdx < sheetConfig.columns.length; colIdx++) {
const col = sheetConfig.columns[colIdx];
const colL = colLetter(colIdx + 1);
const rangeStr = `${colL}2:${colL}${VALIDATION_END}`;
// ── 수식 컬럼: 2행에만 수식 세팅 (사용자가 아래로 복사하거나 테이블 확장) ──
if (col.customFormula) {
let formula = col.customFormula;
formula = formula.replace(/\{col:(\w+)\}/g, (_, key) => {
const idx = sheetConfig.columns.findIndex(c => c.key === key);
return idx >= 0 ? `${colLetter(idx + 1)}2` : `"?"`;
});
formula = formula.replace(/\{itemCount\}/g, String(itemProcessMappings?.length || 9999));
// 2행~FORMULA_END 행에 수식 삽입 (잠금 + 회색 배경)
for (let r = 2; r <= FORMULA_END; r++) {
// 상대참조만 치환 (A2→A{r}), 절대참조($A$2)는 유지
const rowFormula = formula.replace(/(?<!\$)([A-Z]+)(?<!\$)2(?![0-9])/g, (match, c) => `${c}${r}`);
const cell = ws.getCell(`${colL}${r}`);
cell.value = { formula: rowFormula } as any;
cell.protection = { locked: true };
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFF8FAFC" } };
cell.font = { size: 10, color: { argb: "FF64748B" } };
cell.border = { top: { style: "hair", color: { argb: "FFE2E8F0" } }, bottom: { style: "hair", color: { argb: "FFE2E8F0" } }, left: { style: "hair", color: { argb: "FFE2E8F0" } }, right: { style: "hair", color: { argb: "FFE2E8F0" } } };
}
continue;
}
if (col.autoFill && refSheet && refCols.length > 0) {
const lookupColIdx = sheetConfig.columns.findIndex(c => c.key === col.autoFill!.lookupColumn);
const refColIdx = refCols.findIndex(c => c.key === col.autoFill!.referenceColumn) + 1;
if (lookupColIdx >= 0 && refColIdx > 0) {
const refRange = `'${config.referenceSheet!.name}'!$A$2:$${colLetter(refCols.length)}$${refDataRows.length + 1}`;
for (let r = 2; r <= FORMULA_END; r++) {
const lookupCell = `${colLetter(lookupColIdx + 1)}${r}`;
const cell = ws.getCell(`${colL}${r}`);
cell.value = { formula: `IFERROR(VLOOKUP(${lookupCell},${refRange},${refColIdx},FALSE),"")` } as any;
cell.protection = { locked: true };
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFF8FAFC" } };
cell.font = { size: 10, color: { argb: "FF64748B" } };
cell.border = { top: { style: "hair", color: { argb: "FFE2E8F0" } }, bottom: { style: "hair", color: { argb: "FFE2E8F0" } }, left: { style: "hair", color: { argb: "FFE2E8F0" } }, right: { style: "hair", color: { argb: "FFE2E8F0" } } };
}
}
continue;
}
// ── data validation: 범위 단위로 한 번에 설정 ──
// enableWhen (참조시트 직접 조회)
if (col.enableWhen && refSheet && refCols.length > 0 && refDataRows.length > 0) {
const condRefColIdx = refCols.findIndex(c => c.key === col.enableWhen!.column);
const lookupKeyColIdx = sheetConfig.columns.findIndex(c => c.autoFill?.referenceColumn === col.enableWhen!.column);
const lookupSourceColIdx = lookupKeyColIdx >= 0
? sheetConfig.columns.findIndex(c => c.key === sheetConfig.columns[lookupKeyColIdx].autoFill?.lookupColumn)
: -1;
if (condRefColIdx >= 0 && lookupSourceColIdx >= 0) {
const lookupColL = colLetter(lookupSourceColIdx + 1);
const refRange = `'${config.referenceSheet!.name}'!$A$2:$A$${refDataRows.length + 1}`;
const refValueRange = `'${config.referenceSheet!.name}'!$${colLetter(condRefColIdx + 1)}$2:$${colLetter(condRefColIdx + 1)}$${refDataRows.length + 1}`;
const keyword = col.enableWhen.equals.replace(/[()]/g, "").slice(0, 2);
const condColLabel = sheetConfig.columns.find(c => c.key === col.enableWhen!.column)?.label || col.enableWhen!.column;
// 상대 참조 (2행 기준, 범위 적용 시 자동 조정)
(ws as any).dataValidations.add(rangeStr, {
type: "custom",
allowBlank: true,
formulae: [`ISNUMBER(SEARCH("${keyword}",IFERROR(INDEX(${refValueRange},MATCH(${lookupColL}2,${refRange},0)),"")))`],
showErrorMessage: true,
errorTitle: "입력 불가",
error: `${condColLabel}이(가) "${col.enableWhen.equals}"일 때만 입력 가능합니다`,
});
}
continue;
}
// INDIRECT 동적 드롭다운
if (col.dropdown?.source === "indirect") {
const prefix = col.dropdown.indirectPrefix || "P_";
const keyColName = col.dropdown.indirectKeyColumn;
if (!keyColName) continue;
const keyColIdx = sheetConfig.columns.findIndex(c => c.key === keyColName);
if (keyColIdx >= 0) {
const keyColL = colLetter(keyColIdx + 1);
if (prefix === "ACC_" && refDataRows.length > 0) {
const refNameRange = `'${config.referenceSheet!.name}'!$A$2:$A$${refDataRows.length + 1}`;
(ws as any).dataValidations.add(rangeStr, {
type: "list",
allowBlank: true,
formulae: [`INDIRECT("ACC_"&MATCH(${keyColL}2,${refNameRange},0))`],
showErrorMessage: false,
});
} else {
// sanitizeNameForExcel과 동일한 치환: 비영숫자 → _
const sanitizeFormula = `SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(${keyColL}2,"-","_"),".","_")," ","_"),"/","_"),"(","_"),")","_"),"#","_")`;
(ws as any).dataValidations.add(rangeStr, {
type: "list",
allowBlank: true,
formulae: [`INDIRECT("${prefix}"&${sanitizeFormula})`],
showErrorMessage: false,
});
}
}
continue;
}
// 일반 드롭다운
if (col.type === "dropdown") {
const optionKey = `${sheetConfig.name}:${col.key}`;
const globalKey = col.key;
const configValues = col.dropdown?.values;
const values =
(configValues && configValues.length > 0 ? configValues : null) ||
dropdownOptions?.[optionKey] ||
dropdownOptions?.[globalKey] ||
[];
if (values.length > 0) {
// 품목목록 시트 참조 (품목명 등 대량 드롭다운)
const isItemNameDropdown = hasItemProcess && values.length === itemProcessMappings!.length
&& values[0] === itemProcessMappings![0]?.itemName;
if (isItemNameDropdown) {
(ws as any).dataValidations.add(rangeStr, {
type: "list",
allowBlank: !col.required,
formulae: [`'_품목목록'!$B$1:$B$${itemProcessMappings!.length}`],
showErrorMessage: true,
errorTitle: "입력 오류",
error: "품목 목록에서 선택해주세요",
});
} else {
const joined = `"${values.join(",")}"`;
if (joined.length <= 255) {
(ws as any).dataValidations.add(rangeStr, {
type: "list",
allowBlank: !col.required,
formulae: [joined],
showErrorMessage: true,
errorTitle: "입력 오류",
error: `다음 중 선택: ${values.slice(0, 5).join(", ")}${values.length > 5 ? " ..." : ""}`,
});
} else {
const listSheetName = `_list_${sheetConfig.name}_${col.key}`.slice(0, 31);
let listSheet = workbook.getWorksheet(listSheetName);
if (!listSheet) {
listSheet = workbook.addWorksheet(listSheetName, { state: "veryHidden" });
values.forEach((v, i) => { listSheet!.getCell(`A${i + 1}`).value = v; });
}
(ws as any).dataValidations.add(rangeStr, {
type: "list",
allowBlank: !col.required,
formulae: [`'${listSheetName}'!$A$1:$A$${values.length}`],
showErrorMessage: true,
errorTitle: "입력 오류",
error: "목록에서 선택해주세요",
});
}
}
}
continue;
}
// 숫자 포맷
if (col.type === "number") {
ws.getColumn(colIdx + 1).numFmt = "#,##0.##";
}
}
// 시트 보호 — 수식/자동입력 컬럼 잠금, 편집 컬럼 해제
const hasLockedCols = sheetConfig.columns.some(c => c.autoFill || c.readOnly || c.customFormula);
if (hasLockedCols) {
// Excel 기본: 모든 셀 locked=true → 편집 가능 컬럼만 unlocked 처리
const editableColIndices: number[] = [];
sheetConfig.columns.forEach((c, ci) => {
if (!c.autoFill && !c.readOnly && !c.customFormula) {
editableColIndices.push(ci);
}
});
// 편집 가능 컬럼만 unlock (FORMULA_END 행까지)
for (const ci of editableColIndices) {
const cl = colLetter(ci + 1);
for (let r = 2; r <= FORMULA_END; r++) {
const editCell = ws.getCell(`${cl}${r}`);
editCell.protection = { locked: false };
editCell.border = { top: { style: "hair", color: { argb: "FFE2E8F0" } }, bottom: { style: "hair", color: { argb: "FFE2E8F0" } }, left: { style: "hair", color: { argb: "FFE2E8F0" } }, right: { style: "hair", color: { argb: "FFE2E8F0" } } };
}
}
ws.protect("", {
selectLockedCells: true,
selectUnlockedCells: true,
formatCells: true,
sort: true,
autoFilter: true,
});
}
// 고정틀
ws.views = [{ state: "frozen", ySplit: 1, xSplit: 0 }];
}
// ═══════════════════ 4. 안내 시트 ═══════════════════
const guideSheet = workbook.addWorksheet("안내");
guideSheet.getColumn(1).width = 20;
guideSheet.getColumn(2).width = 50;
// Config 기반으로 안내시트 자동 생성
const firstSheetCols = config.sheets[0]?.columns || [];
const lockedCols = firstSheetCols.filter(c => c.autoFill || c.readOnly || c.customFormula).map(c => c.label);
const editableCols = firstSheetCols.filter(c => !c.autoFill && !c.readOnly && !c.customFormula);
const guideData: [string, string][] = [
["템플릿 정보", ""],
["파일명", config.templateName],
["생성일시", new Date().toLocaleString("ko-KR")],
...(hasItemProcess ? [["품목 수", `${itemProcessMappings!.length}`] as [string, string]] : []),
["시트 구성", config.sheets.map(s => s.name).join(", ")],
["", ""],
["컬럼 설명", ""],
// 컬럼별 자동 설명 생성
...firstSheetCols.map((col): [string, string] => {
const parts: string[] = [];
if (col.required) parts.push("필수");
if (col.autoFill) parts.push(`${firstSheetCols.find(c => c.key === col.autoFill!.lookupColumn)?.label || ""} 선택 시 자동 입력 (수정 불가)`);
else if (col.customFormula) parts.push("자동 입력 (수정 불가)");
else if (col.readOnly) parts.push("읽기전용");
else if (col.dropdown?.source === "indirect") parts.push("연동 드롭다운 (관련 컬럼 선택 후 목록 표시)");
else if (col.type === "dropdown") parts.push("드롭다운에서 선택");
else if (col.type === "number") parts.push("숫자 입력");
else parts.push("텍스트 입력");
if (col.enableWhen) parts.push(`${firstSheetCols.find(c => c.key === col.enableWhen!.column)?.label || col.enableWhen!.column}이(가) "${col.enableWhen.equals}"일 때만 입력 가능`);
return [col.label, parts.join(" — ")];
}),
["", ""],
// 조건부 규칙이 있으면 자동 설명
...(config.conditionalRules && config.conditionalRules.length > 0 ? [
["조건별 입력 규칙", ""] as [string, string],
...config.conditionalRules.map((rule): [string, string] => {
const reqLabels = rule.require.map(k => firstSheetCols.find(c => c.key === k)?.label || k).join(", ");
const ignLabels = rule.ignore.map(k => firstSheetCols.find(c => c.key === k)?.label || k).join(", ");
const condLabel = firstSheetCols.find(c => c.key === rule.when.column)?.label || rule.when.column;
return [rule.when.equals, `${condLabel}이(가) "${rule.when.equals}"일 때: ${reqLabels} 필수${ignLabels ? `, ${ignLabels} 무시` : ""}`];
}),
["", ""] as [string, string],
] : []),
["사용 방법", ""],
["1단계", `하단의 시트(${config.sheets.map(s => s.name).join(", ")})로 이동`],
...editableCols.slice(0, 3).map((col, i): [string, string] =>
[`${i + 2}단계`, `${col.label}${col.type === "dropdown" ? "을(를) 드롭다운에서 선택" : "을(를) 입력"}`]
),
[`${Math.min(editableCols.length, 3) + 2}단계`, "필요한 시트만 작성 (사용하지 않는 시트는 비워두세요)"],
[`${Math.min(editableCols.length, 3) + 3}단계`, "작성 완료 후 시스템에서 파일 업로드"],
["", ""],
["주의사항", ""],
...(lockedCols.length > 0 ? [[
"1", `잠금된 셀(${lockedCols.join(", ")})은 자동 입력됩니다. 직접 수정하지 마세요.`
] as [string, string]] : []),
["2", "사용하지 않는 시트는 비워두면 자동으로 무시됩니다."],
["3", "기준 데이터가 변경된 경우 템플릿을 다시 다운로드해주세요."],
["4", "업로드 시 입력 데이터를 자동 검증합니다. 오류가 있으면 상세 내용이 표시됩니다."],
];
guideData.forEach(([a, b]) => {
const row = guideSheet.addRow([a, b]);
if (["템플릿 정보", "컬럼 설명", "사용 방법", "조건별 입력 규칙", "주의사항"].includes(a)) {
row.eachCell((cell) => {
cell.font = { bold: true, size: 12, color: { argb: "FF1E40AF" } };
});
}
});
// ═══════════════════ 5. _meta 시트 (숨김) ═══════════════════
const metaSheet = workbook.addWorksheet("_meta", { state: "veryHidden" });
metaSheet.getColumn(1).width = 20;
metaSheet.getColumn(2).width = 50;
const metaEntries: [string, string][] = [
["version_hash", versionHash],
["created_at", new Date().toISOString()],
["template_name", config.templateName],
["item_count", hasItemProcess ? String(itemProcessMappings!.length) : "0"],
...(Object.entries(extraMeta || {}) as [string, string][]),
...(Object.entries(config.extraMeta || {}) as [string, string][]),
];
metaEntries.forEach(([k, v]) => metaSheet.addRow([k, v]));
// ═══════════════════ 6. 시트 순서 조정 ═══════════════════
const guideIdx = workbook.worksheets.findIndex((ws) => ws.name === "안내");
if (guideIdx > 0) {
(guideSheet as any).orderNo = 0;
let order = 1;
for (const ws of workbook.worksheets) {
if (ws.name !== "안내") {
(ws as any).orderNo = order++;
}
}
}
const buffer = await workbook.xlsx.writeBuffer();
return buffer;
}
/** 현재 DB 데이터 기반 해시 재생성 (업로드 검증용) */
export function regenerateHash(
referenceData: Record<string, any>[],
dropdownOptions: Record<string, string[]>,
itemProcessMappings?: ItemProcessMapping[]
): string {
const hashSource = JSON.stringify({
referenceData,
dropdownOptions,
itemProcessMappings: itemProcessMappings?.map(m => ({
itemCode: m.itemCode,
processes: m.processes.map(p => p.name),
})),
});
return generateHash(hashSource);
}

View File

@@ -0,0 +1,327 @@
/**
* SmartExcelUpload — 템플릿 파서
* 업로드된 엑셀 파일 파싱 + 해시 검증 + 데이터 검증
*/
import ExcelJS from "exceljs";
import type {
SmartExcelUploadConfig,
ParseResult,
ParsedSheetData,
ValidationError,
ConditionalRule,
ItemProcessMapping,
} from "./types";
import { regenerateHash } from "./templateGenerator";
export interface ParseOptions {
config: SmartExcelUploadConfig;
file: File;
/** 현재 DB의 참조 데이터 (해시 검증용) */
currentReferenceData: Record<string, any>[];
/** 현재 DB의 드롭다운 옵션 (해시 검증용) */
currentDropdownOptions: Record<string, string[]>;
/** 현재 DB의 품목공정 매핑 (해시 검증용) */
currentItemProcessMappings?: ItemProcessMapping[];
/** 참조 데이터 매핑 (라벨→코드 변환 등) */
labelToCodeMap?: Record<string, Record<string, string>>;
}
/** 셀 값을 문자열로 안전 변환 */
function cellToString(cell: ExcelJS.Cell): string {
const val = cell.value;
if (val === null || val === undefined) return "";
// 수식 객체
if (typeof val === "object" && "formula" in val) {
if ("result" in val) {
return String((val as any).result ?? "");
}
// 수식만 있고 결과 없음 (미계산) → 빈 문자열
return "";
}
// RichText
if (typeof val === "object" && "richText" in val) {
return (val as any).richText.map((r: any) => r.text).join("");
}
return String(val).trim();
}
/** _meta 시트에서 메타 정보 읽기 */
function readMeta(
workbook: ExcelJS.Workbook
): Record<string, string> | null {
const metaSheet = workbook.getWorksheet("_meta");
if (!metaSheet) return null;
const meta: Record<string, string> = {};
metaSheet.eachRow((row) => {
const key = cellToString(row.getCell(1));
const value = cellToString(row.getCell(2));
if (key) meta[key] = value;
});
return meta;
}
/** 엑셀 파일 파싱 + 검증 */
export async function parseTemplate(
options: ParseOptions
): Promise<ParseResult> {
const {
config,
file,
currentReferenceData,
currentDropdownOptions,
currentItemProcessMappings,
labelToCodeMap,
} = options;
const errors: ValidationError[] = [];
const warnings: string[] = [];
const data: ParsedSheetData[] = [];
// 파일 읽기
const arrayBuffer = await file.arrayBuffer();
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(arrayBuffer);
// ═══════════════════ 1. 메타 검증 ═══════════════════
const meta = readMeta(workbook);
if (!meta) {
return {
success: false,
data: [],
errors: [],
warnings: [
"공식 템플릿이 아닙니다. 템플릿을 다운로드 후 사용해주세요.",
],
};
}
// 해시 검증
const currentHash = regenerateHash(
currentReferenceData,
currentDropdownOptions,
currentItemProcessMappings
);
if (meta.version_hash && meta.version_hash !== currentHash) {
return {
success: false,
data: [],
errors: [],
warnings: [
"기준 데이터가 변경되었습니다. 최신 템플릿을 다시 다운로드해주세요.",
],
};
}
// ═══════════════════ 2. 시트별 파싱 ═══════════════════
for (const sheetConfig of config.sheets) {
const ws = workbook.getWorksheet(sheetConfig.name);
if (!ws) continue;
const rows: Record<string, any>[] = [];
// 헤더 확인 (1행)
const headerRow = ws.getRow(1);
const colMap: Record<number, string> = {};
headerRow.eachCell((cell, colNumber) => {
const label = cellToString(cell);
const col = sheetConfig.columns.find((c) => c.label === label);
if (col) colMap[colNumber] = col.key;
});
// 사용자 입력 컬럼만 추출 (autoFill/readOnly/customFormula 제외)
const userInputKeys = new Set(
sheetConfig.columns
.filter((c) => !c.readOnly && !c.autoFill && !c.customFormula)
.map((c) => c.key)
);
// 데이터 행 파싱 (2행부터)
ws.eachRow((row, rowNumber) => {
if (rowNumber <= 1) return;
// 빈 행 체크: 사용자 입력 컬럼만 확인 (수식 셀 무시)
let hasData = false;
for (const [colNum, key] of Object.entries(colMap)) {
if (!userInputKeys.has(key)) continue;
const val = cellToString(row.getCell(Number(colNum)));
if (val) { hasData = true; break; }
}
if (!hasData) return;
const rowData: Record<string, any> = {};
for (const [colNum, key] of Object.entries(colMap)) {
let value = cellToString(row.getCell(Number(colNum)));
// 라벨→코드 변환
if (labelToCodeMap?.[key] && value) {
const code = labelToCodeMap[key][value];
if (code) {
rowData[`${key}_label`] = value;
value = code;
}
}
rowData[key] = value;
}
// ── 행별 검증 ──
// 필수값 검증
for (const col of sheetConfig.columns) {
if (col.readOnly || col.autoFill || col.customFormula) continue;
const value = rowData[col.key];
const isEmpty = !value && value !== 0;
// 조건부 규칙 적용
// when.column이 autoFill/수식 컬럼이면 값이 미계산일 수 있으므로
// labelToCodeMap 역매핑 또는 참조데이터에서 직접 조회
if (config.conditionalRules) {
const resolveCondValue = (colKey: string): string => {
// 직접 입력된 값이 있으면 사용
if (rowData[colKey]) return rowData[colKey];
// autoFill 컬럼이면: lookup 기준 컬럼의 값으로 참조데이터에서 조회
const colDef = sheetConfig.columns.find(c => c.key === colKey);
if (colDef?.autoFill && currentReferenceData.length > 0) {
const lookupVal = rowData[colDef.autoFill.lookupColumn];
if (lookupVal) {
const refRow = currentReferenceData.find(r =>
r[config.referenceSheet?.columns?.[0]?.key || "label"] === lookupVal
);
if (refRow) return refRow[colDef.autoFill.referenceColumn] || "";
}
}
return "";
};
const applicableRules = config.conditionalRules.filter(
(rule) => resolveCondValue(rule.when.column) === rule.when.equals
);
for (const rule of applicableRules) {
// 필수 컬럼 체크
if (rule.require.includes(col.key) && isEmpty) {
const condLabel = sheetConfig.columns.find(c => c.key === rule.when.column)?.label || rule.when.column;
errors.push({
sheet: sheetConfig.name,
row: rowNumber,
column: col.label,
message: `${condLabel}이(가) "${rule.when.equals}"일 때 ${col.label}은(는) 필수입니다`,
});
}
}
// 무시 컬럼이면 검증 스킵
const isIgnored = applicableRules.some((rule) =>
rule.ignore.includes(col.key)
);
if (isIgnored) continue;
}
// 일반 필수값 검증
if (col.required && isEmpty) {
errors.push({
sheet: sheetConfig.name,
row: rowNumber,
column: col.label,
message: `${col.label}은(는) 필수 항목입니다`,
});
}
}
// 드롭다운 값 유효성 검증
for (const col of sheetConfig.columns) {
if (col.type !== "dropdown" || col.readOnly || col.autoFill) continue;
const value = rowData[col.key];
if (!value) continue;
const optionKey = `${sheetConfig.name}:${col.key}`;
const globalKey = col.key;
const validValues =
col.dropdown?.values ||
currentDropdownOptions[optionKey] ||
currentDropdownOptions[globalKey] ||
[];
if (validValues.length > 0 && !validValues.includes(value)) {
// 라벨로 입력한 경우도 체크
const labelValue = rowData[`${col.key}_label`] || value;
if (!validValues.includes(labelValue)) {
errors.push({
sheet: sheetConfig.name,
row: rowNumber,
column: col.label,
message: `유효하지 않은 값: "${value}". 가능한 값: ${validValues.slice(0, 5).join(", ")}${validValues.length > 5 ? " ..." : ""}`,
});
}
}
}
// INDIRECT 드롭다운 유효성 검증 (품목별 공정 등)
if (currentItemProcessMappings && currentItemProcessMappings.length > 0) {
for (const col of sheetConfig.columns) {
if (col.dropdown?.source !== "indirect" || col.dropdown.indirectPrefix === "ACC_") continue;
const value = rowData[col.key];
if (!value) continue;
// 품목코드 resolve
const keyColName = col.dropdown.indirectKeyColumn || "";
let itemCode = rowData[keyColName] || "";
// customFormula 컬럼이면 수식 결과가 없을 수 있음 → 품목명으로 역매핑
if (!itemCode) {
// 사용자가 입력한 컬럼 중 itemProcessMappings의 itemName과 매칭되는 값 찾기
for (const [, val] of Object.entries(rowData)) {
if (val && typeof val === "string") {
const mapping = currentItemProcessMappings.find(m => m.itemName === val);
if (mapping) { itemCode = mapping.itemCode; break; }
}
}
}
if (itemCode) {
const mapping = currentItemProcessMappings.find(m => m.itemCode === itemCode);
if (mapping) {
const valid = mapping.processes.some(p => p.code === value || p.name === value);
if (!valid) {
const displayValue = rowData[`${col.key}_label`] || mapping.processes.find(p => p.code === value)?.name || value;
const validNames = mapping.processes.map(p => p.name);
errors.push({
sheet: sheetConfig.name,
row: rowNumber,
column: col.label,
message: `"${displayValue}"은(는) 해당 품목의 유효한 공정이 아닙니다${validNames.length > 0 ? `. 가능한 공정: ${validNames.join(", ")}` : ""}`,
});
}
}
}
}
}
rows.push(rowData);
});
if (rows.length > 0) {
data.push({
sheetName: sheetConfig.name,
typeKey: sheetConfig.typeKey,
rows,
});
}
}
// 데이터 없음 체크
if (data.length === 0 || data.every((d) => d.rows.length === 0)) {
warnings.push("업로드할 데이터가 없습니다. 시트에 데이터를 입력해주세요.");
}
return {
success: errors.length === 0 && warnings.length === 0,
data,
errors,
warnings,
};
}

View File

@@ -0,0 +1,151 @@
/**
* SmartExcelUpload — 설정 기반 엑셀 업로드 공통 모듈 타입 정의
*/
/** 드롭다운 소스 설정 */
export interface DropdownConfig {
/** 드롭다운 소스 유형 */
source: "category" | "custom" | "reference" | "indirect";
/** 카테고리 조회 시 테이블명 */
tableName?: string;
/** 카테고리 조회 시 컬럼명 */
columnName?: string;
/** 고정 값 목록 (source: "custom") */
values?: string[];
/** 참조시트 컬럼에서 가져올 때 (source: "reference") — VLOOKUP 기반 */
referenceColumn?: string;
/** INDIRECT 연동 시 참조할 컬럼 키 (source: "indirect") — 품목별 공정 등 */
indirectKeyColumn?: string;
/** INDIRECT 이름 범위 prefix (source: "indirect") — 예: "ACC_" → INDIRECT("ACC_" & 셀값) */
indirectPrefix?: string;
}
/** 컬럼 정의 */
export interface SmartColumn {
/** DB 컬럼명 */
key: string;
/** 엑셀 헤더 라벨 */
label: string;
/** 필수 여부 */
required?: boolean;
/** 데이터 타입 */
type: "text" | "number" | "date" | "dropdown";
/** 드롭다운 설정 */
dropdown?: DropdownConfig;
/** VLOOKUP 자동 입력 (참조시트에서 자동으로 값 가져옴, 사용자 입력 불가) */
autoFill?: {
/** 참조시트에서 lookup할 키 컬럼 (같은 시트의 어떤 컬럼 값을 기준으로) */
lookupColumn: string;
/** 참조시트에서 가져올 컬럼명 */
referenceColumn: string;
};
/** 조건부 활성화 — 다른 컬럼 값이 일치할 때만 입력 가능 */
enableWhen?: {
/** 참조할 컬럼 키 */
column: string;
/** 해당 값일 때만 입력 가능 */
equals: string;
};
/** 조건부 비활성화 — 다른 컬럼 값이 일치하면 입력 차단 */
disableWhen?: {
/** 참조할 컬럼 키 */
column: string;
/** 해당 값일 때 입력 차단 */
equals: string;
};
/** 커스텀 수식 — {col:key} 형태로 같은 행의 다른 컬럼 셀 참조 가능 */
customFormula?: string;
/** 읽기전용 (자동채움 등) */
readOnly?: boolean;
/** 컬럼 너비 (기본 18) */
width?: number;
}
/** 시트 정의 */
export interface SheetConfig {
/** 시트명 (예: "수입검사") */
name: string;
/** DB 저장 시 타입 구분 값 (예: "수입검사") */
typeKey?: string;
/** 컬럼 정의 */
columns: SmartColumn[];
}
/** 참조시트 정의 — 검사기준정보 등 lookup 데이터 */
export interface ReferenceSheetConfig {
/** 시트명 */
name: string;
/** 컬럼 정의 */
columns: { key: string; label: string }[];
/** 참조 데이터 (런타임에 주입) */
data?: Record<string, any>[];
}
/** 조건부 검증 규칙 */
export interface ConditionalRule {
/** 조건: 어떤 컬럼이 어떤 값일 때 */
when: { column: string; equals: string };
/** 필수가 되는 컬럼들 */
require: string[];
/** 무시해도 되는 컬럼들 (비어있어도 OK) */
ignore: string[];
}
/** 품목별 공정 매핑 (INDIRECT 동적 드롭다운용) */
export interface ItemProcessMapping {
/** 품목코드 */
itemCode: string;
/** 품목명 */
itemName: string;
/** 해당 품목의 공정 목록 */
processes: { code: string; name: string }[];
}
/** INDIRECT prefix(ACC_) 기반 동적 드롭다운 옵션 정의 */
export interface IndirectOptionsConfig {
/** 참조시트에서 조건을 판단할 컬럼 키 (예: "judgment_criteria") */
conditionColumn: string;
/** 조건값→옵션 매핑 */
optionsByCondition: Record<string, string[]>;
/** 참조시트에서 선택옵션을 가져올 컬럼 키 (콤마 구분 문자열, 예: "selection_options") */
selectionOptionsColumn?: string;
}
/** 전체 설정 */
export interface SmartExcelUploadConfig {
/** 템플릿 파일명 (예: "품목검사정보") */
templateName: string;
/** 시트 정의 목록 */
sheets: SheetConfig[];
/** 참조시트 설정 (없으면 생략) */
referenceSheet?: ReferenceSheetConfig;
/** 조건부 검증 규칙 (없으면 단순 필수값 체크만) */
conditionalRules?: ConditionalRule[];
/** INDIRECT ACC_ 동적 드롭다운 옵션 설정 (없으면 ACC_ 시트 미생성) */
indirectOptions?: IndirectOptionsConfig;
/** 추가 메타 정보 (템플릿에 포함) */
extraMeta?: Record<string, string>;
}
/** 파싱 결과 — 시트별 데이터 */
export interface ParsedSheetData {
sheetName: string;
typeKey?: string;
rows: Record<string, any>[];
}
/** 검증 에러 */
export interface ValidationError {
sheet: string;
row: number;
column: string;
message: string;
}
/** 파싱 + 검증 결과 */
export interface ParseResult {
success: boolean;
data: ParsedSheetData[];
errors: ValidationError[];
warnings: string[];
}