Merge remote-tracking branch 'origin/jskim-node' into jskim-node
; Conflicts: ; frontend/app/(main)/COMPANY_30/sales/order/page.tsx
This commit is contained in:
@@ -892,6 +892,40 @@ export class ApprovalRequestController {
|
||||
const userName = req.user?.userName || "";
|
||||
const deptName = req.user?.deptName || "";
|
||||
|
||||
// 🔒 중복 결재 차단: 같은 target에 활성/완료된 결재가 있으면 거부
|
||||
// (rejected, cancelled는 재상신 허용)
|
||||
if (target_record_id) {
|
||||
const existing = await queryOne<any>(
|
||||
`SELECT request_id, status FROM approval_requests
|
||||
WHERE target_table = $1 AND target_record_id = $2 AND company_code = $3
|
||||
AND status IN ('requested', 'in_progress', 'approved', 'post_pending')
|
||||
ORDER BY request_id DESC LIMIT 1`,
|
||||
[target_table, safeTargetRecordId, companyCode]
|
||||
);
|
||||
if (existing) {
|
||||
const statusLabel: Record<string, string> = {
|
||||
requested: "요청됨", in_progress: "결재중", approved: "승인완료", post_pending: "후결대기",
|
||||
};
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: `이미 ${statusLabel[existing.status] || existing.status} 상태의 결재가 존재합니다. (요청 ID: ${existing.request_id})`,
|
||||
error: { code: "DUPLICATE_APPROVAL", details: existing },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 🔒 자기 자신 결재 차단: approval_type이 'self'가 아니면 결재선에 본인 포함 불가
|
||||
if (approval_type !== "self" && Array.isArray(approvers)) {
|
||||
const selfInLine = approvers.find((a: any) => (a.userId || a.user_id) === userId);
|
||||
if (selfInLine) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "결재선에 본인을 포함할 수 없습니다. 자기결재(전결)는 별도 유형을 사용해 주세요.",
|
||||
error: { code: "SELF_APPROVER_NOT_ALLOWED" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// approval_mode를 target_record_data에 병합 저장 (하위호환)
|
||||
const mergedRecordData = {
|
||||
...(target_record_data || {}),
|
||||
|
||||
@@ -207,6 +207,17 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
}
|
||||
const insertedDetails: any[] = [];
|
||||
|
||||
// 기존 디테일이 있으면 스킵 (멱등성 — 같은 inbound_number로 2번 호출 방지)
|
||||
const existingDetails = await client.query(
|
||||
`SELECT COUNT(*) AS cnt FROM inbound_detail WHERE company_code = $1 AND inbound_id = $2`,
|
||||
[companyCode, inboundNumber]
|
||||
);
|
||||
if (parseInt(existingDetails.rows[0].cnt, 10) > 0) {
|
||||
await client.query("COMMIT");
|
||||
client.release();
|
||||
return res.json({ success: true, data: [], message: "이미 등록된 입고입니다." });
|
||||
}
|
||||
|
||||
// 2. 디테일 INSERT (inbound_detail) + 재고/발주 업데이트
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
@@ -907,6 +907,61 @@ export async function getTableData(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 집계 조회 (SUM/COUNT)
|
||||
* POST /api/table-management/tables/:tableName/aggregate
|
||||
*/
|
||||
export async function getTableAggregate(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const { columns, autoFilter } = req.body;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
if (!tableName || !columns || !Array.isArray(columns)) {
|
||||
res.status(400).json({ success: false, message: "tableName과 columns 배열이 필요합니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const validCols = columns.filter((c: any) =>
|
||||
c.column && c.func && /^[a-zA-Z0-9_]+$/.test(c.column) && ["sum", "count", "avg", "min", "max"].includes(c.func)
|
||||
);
|
||||
if (validCols.length === 0) {
|
||||
res.status(400).json({ success: false, message: "유효한 집계 컬럼이 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const selectParts = validCols.map((c: any) => {
|
||||
const col = c.column.replace(/[^a-zA-Z0-9_]/g, "");
|
||||
return `${c.func}(COALESCE(CAST(NULLIF(${col}, '') AS numeric), 0)) AS "${c.func}_${col}"`;
|
||||
});
|
||||
|
||||
let whereClause = "";
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (autoFilter !== false && companyCode && companyCode !== "*") {
|
||||
whereClause = `WHERE company_code = $${paramIdx}`;
|
||||
params.push(companyCode);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = (await import("../database/db")).getPool();
|
||||
const safeTable = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
||||
const result = await pool.query(
|
||||
`SELECT ${selectParts.join(", ")} FROM ${safeTable} ${whereClause}`,
|
||||
params
|
||||
);
|
||||
|
||||
res.json({ success: true, data: result.rows[0] || {} });
|
||||
} catch (error: any) {
|
||||
logger.error("테이블 집계 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 데이터 추가
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
updateColumnInputType,
|
||||
updateTableLabel,
|
||||
getTableData,
|
||||
getTableRecord, // 🆕 단일 레코드 조회
|
||||
getTableRecord,
|
||||
getTableAggregate,
|
||||
addTableData,
|
||||
editTableData,
|
||||
deleteTableData,
|
||||
@@ -193,6 +194,7 @@ router.get("/health", checkDatabaseConnection);
|
||||
* POST /api/table-management/tables/:tableName/data
|
||||
*/
|
||||
router.post("/tables/:tableName/data", getTableData);
|
||||
router.post("/tables/:tableName/aggregate", getTableAggregate);
|
||||
|
||||
/**
|
||||
* 단일 레코드 조회 (자동 입력용)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2367,26 +2367,24 @@ export class TableManagementService {
|
||||
const total = parseInt(countResult[0].count);
|
||||
|
||||
// 데이터 조회 (main 별칭 추가)
|
||||
const dataQuery = `
|
||||
SELECT main.* FROM ${safeTableName} main
|
||||
${whereClause}
|
||||
${orderClause}
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
// size=0 이면 LIMIT 없이 전체 반환 (마스터 참조 데이터 조회용)
|
||||
const usePaging = size > 0;
|
||||
const dataQuery = usePaging
|
||||
? `SELECT main.* FROM ${safeTableName} main ${whereClause} ${orderClause} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`
|
||||
: `SELECT main.* FROM ${safeTableName} main ${whereClause} ${orderClause}`;
|
||||
|
||||
logger.info(`🔍 실행할 SQL: ${dataQuery}`);
|
||||
logger.info(
|
||||
`🔍 파라미터: ${JSON.stringify([...searchValues, size, offset])}`
|
||||
);
|
||||
const queryParams = usePaging ? [...searchValues, size, offset] : [...searchValues];
|
||||
logger.info(`🔍 파라미터: ${JSON.stringify(queryParams)}`);
|
||||
|
||||
let data = await query<any>(dataQuery, [...searchValues, size, offset]);
|
||||
let data = await query<any>(dataQuery, queryParams);
|
||||
|
||||
// 🎯 파일 컬럼이 있으면 파일 정보 보강
|
||||
if (fileColumns.length > 0) {
|
||||
data = await this.enrichFileData(data, fileColumns, safeTableName);
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / size);
|
||||
const totalPages = usePaging ? Math.ceil(total / size) : 1;
|
||||
|
||||
logger.info(
|
||||
`테이블 데이터 조회 완료: ${tableName}, 총 ${total}건, ${data.length}개 반환`
|
||||
|
||||
@@ -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)으로 범위 조절 가능
|
||||
@@ -104,10 +104,10 @@ export default function InboundOutboundPage() {
|
||||
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
|
||||
if (itemCodes.length > 0) {
|
||||
try {
|
||||
// 단위 카테고리 코드→라벨 매핑 로드
|
||||
// 재고단위 카테고리 코드→라벨 매핑 로드
|
||||
let unitLabelMap: Record<string, string> = {};
|
||||
try {
|
||||
const catRes = await apiClient.get("/table-categories/item_info/unit/values");
|
||||
const catRes = await apiClient.get("/table-categories/item_info/inventory_unit/values");
|
||||
if (catRes.data?.success && catRes.data.data?.length > 0) {
|
||||
const flatten = (vals: any[]) => {
|
||||
for (const v of vals) {
|
||||
@@ -127,7 +127,7 @@ export default function InboundOutboundPage() {
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
const map: Record<string, { item_name: string; unit: string }> = {};
|
||||
for (const i of items) {
|
||||
const rawUnit = i.unit || "";
|
||||
const rawUnit = i.inventory_unit || "";
|
||||
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit };
|
||||
}
|
||||
setItemMap(map);
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
@@ -388,7 +389,7 @@ export default function ReceivingPage() {
|
||||
const flatRows = useMemo(() => {
|
||||
return data.map((row) => ({
|
||||
...row,
|
||||
inbound_type: resolveInboundType(row.inbound_type),
|
||||
inbound_type: resolveInboundType((row as any).detail_inbound_type || row.inbound_type),
|
||||
source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "",
|
||||
}));
|
||||
}, [data]);
|
||||
@@ -595,7 +596,7 @@ export default function ReceivingPage() {
|
||||
setSelectedItems(
|
||||
grouped.map((g) => ({
|
||||
key: g.id,
|
||||
inbound_type: g.inbound_type || "",
|
||||
inbound_type: (g as any).detail_inbound_type || g.inbound_type || "",
|
||||
reference_number: g.reference_number || "",
|
||||
supplier_code: (g as any).supplier_code || "",
|
||||
supplier_name: g.supplier_name || "",
|
||||
@@ -635,7 +636,7 @@ export default function ReceivingPage() {
|
||||
setPurchaseOrders([]);
|
||||
setShipments([]);
|
||||
setItems([]);
|
||||
setSelectedItems([]);
|
||||
// 선택 품목은 유지 (여러 유형 혼합 가능)
|
||||
setSourcePage(1);
|
||||
setSourceTotalCount(0);
|
||||
loadSourceData(type, undefined, 1);
|
||||
@@ -651,7 +652,7 @@ export default function ReceivingPage() {
|
||||
...prev,
|
||||
{
|
||||
key,
|
||||
inbound_type: "구매입고",
|
||||
inbound_type: modalInboundType,
|
||||
reference_number: po.purchase_no,
|
||||
supplier_code: po.supplier_code,
|
||||
supplier_name: po.supplier_name,
|
||||
@@ -677,7 +678,7 @@ export default function ReceivingPage() {
|
||||
...prev,
|
||||
{
|
||||
key,
|
||||
inbound_type: "반품입고",
|
||||
inbound_type: modalInboundType,
|
||||
reference_number: sh.instruction_no,
|
||||
supplier_code: "",
|
||||
supplier_name: sh.partner_id,
|
||||
@@ -695,15 +696,15 @@ export default function ReceivingPage() {
|
||||
]);
|
||||
};
|
||||
|
||||
// 품목 추가
|
||||
// 품목 추가 (현재 선택된 입고유형 사용)
|
||||
const addItem = (item: ItemSource) => {
|
||||
const key = `item-${item.id}`;
|
||||
const key = `item-${item.id}-${modalInboundType}`;
|
||||
if (selectedItems.some((s) => s.key === key)) return;
|
||||
setSelectedItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key,
|
||||
inbound_type: "기타입고",
|
||||
inbound_type: modalInboundType,
|
||||
reference_number: item.item_number,
|
||||
supplier_code: "",
|
||||
supplier_name: "",
|
||||
@@ -1009,11 +1010,11 @@ export default function ReceivingPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedRows.map((row) => {
|
||||
paginatedRows.map((row, idx) => {
|
||||
const isChecked = checkedIds.includes(row.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
key={`${row.id}-${idx}`}
|
||||
className={cn(
|
||||
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
|
||||
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
|
||||
@@ -1401,6 +1402,7 @@ export default function ReceivingPage() {
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[30px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
|
||||
<TableHead className="w-[70px] p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고유형</TableHead>
|
||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">참조번호</TableHead>
|
||||
<TableHead className="w-[80px] p-2 text-right">
|
||||
@@ -1421,6 +1423,9 @@ export default function ReceivingPage() {
|
||||
<TableCell className="p-2 text-center">
|
||||
{idx + 1}
|
||||
</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Badge variant="outline" className="text-[10px]">{item.inbound_type || modalInboundType}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px] p-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="truncate font-medium" title={item.item_name}>
|
||||
|
||||
@@ -1986,7 +1986,7 @@ export default function BomManagementPage() {
|
||||
|
||||
{/* ─── BOM 등록/수정 모달 ─────────────────── */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditMode ? "BOM 수정" : "BOM 등록"}</DialogTitle>
|
||||
<DialogDescription>{isEditMode ? "BOM 정보를 수정해요" : "새로운 BOM을 등록해요"}</DialogDescription>
|
||||
|
||||
@@ -606,7 +606,7 @@ export default function InspectionManagementPage() {
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-col gap-3 p-3 h-[calc(100vh-4rem)] overflow-auto">
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<div className="bg-card rounded-lg border">
|
||||
|
||||
@@ -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";
|
||||
@@ -34,12 +36,6 @@ const GRID_COLUMNS = [
|
||||
{ key: "is_active", label: "사용여부" },
|
||||
];
|
||||
|
||||
const INSPECTION_TYPES = [
|
||||
{ key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] },
|
||||
{ key: "outgoing_inspection", label: "출하검사", matchLabels: ["출하검사", "출고검사", "출하", "출고"] },
|
||||
{ key: "process_inspection", label: "공정검사", matchLabels: ["공정검사", "공정"] },
|
||||
{ key: "final_inspection", label: "최종검사", matchLabels: ["최종검사", "최종", "완제품검사"] },
|
||||
] as const;
|
||||
|
||||
type InspectionRow = {
|
||||
id: string;
|
||||
@@ -79,6 +75,15 @@ export default function ItemInspectionInfoPage() {
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
|
||||
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
// 검사유형 목록 (검사기준 카테고리 기반)
|
||||
const INSPECTION_TYPES = useMemo(() => {
|
||||
return inspTypeCatOptions.map((cat) => ({
|
||||
key: cat.code,
|
||||
label: cat.label,
|
||||
matchLabels: [cat.code, cat.label],
|
||||
}));
|
||||
}, [inspTypeCatOptions]);
|
||||
const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [judgmentCatOptions, setJudgmentCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspUnitCatOptions, setInspUnitCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
@@ -96,6 +101,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 +478,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 +729,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 +887,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);
|
||||
@@ -752,11 +995,11 @@ export default function ItemInspectionInfoPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{editMode ? "품목검사정보 수정" : "품목검사정보 등록"}</DialogTitle>
|
||||
<DialogDescription className="sr-only">품목검사정보를 등록합니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
|
||||
{/* 품목 정보 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">품목 정보</h4>
|
||||
@@ -918,7 +1161,7 @@ export default function ItemInspectionInfoPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}저장
|
||||
@@ -930,6 +1173,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ const FLAT_COLUMNS = [
|
||||
{ key: "unit_price", label: "단가", source: "detail" },
|
||||
{ key: "amount", label: "금액", source: "detail" },
|
||||
{ key: "due_date", label: "납기일", source: "detail" },
|
||||
{ key: "approval_status", label: "결재상태", source: "master" },
|
||||
{ key: "memo", label: "메모", source: "master" },
|
||||
];
|
||||
|
||||
@@ -66,8 +67,26 @@ const DETAIL_HEADER_COLS = FLAT_COLUMNS.filter((c) => c.source === "detail");
|
||||
// 필터용 전체 키
|
||||
const GRID_COLUMNS_CONFIG = FLAT_COLUMNS.map(({ key, label }) => ({ key, label }));
|
||||
|
||||
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(14) = 15
|
||||
const TOTAL_COLS = 15;
|
||||
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(15) = 16
|
||||
const TOTAL_COLS = 16;
|
||||
|
||||
// 결재상태 라벨/색상
|
||||
const APPROVAL_STATUS_LABEL: Record<string, string> = {
|
||||
requested: "요청",
|
||||
in_progress: "결재중",
|
||||
approved: "승인완료",
|
||||
rejected: "반려",
|
||||
cancelled: "회수",
|
||||
post_pending: "후결대기",
|
||||
};
|
||||
const APPROVAL_STATUS_CLASS: Record<string, string> = {
|
||||
requested: "bg-secondary text-secondary-foreground",
|
||||
in_progress: "bg-primary/10 text-primary border border-primary/20",
|
||||
approved: "bg-emerald-500/10 text-emerald-600 border border-emerald-500/20",
|
||||
rejected: "bg-destructive/10 text-destructive border border-destructive/20",
|
||||
cancelled: "bg-muted text-muted-foreground",
|
||||
post_pending: "bg-warning/10 text-warning",
|
||||
};
|
||||
|
||||
// 헤더 필터 Popover
|
||||
function HeaderFilterPopover({
|
||||
@@ -333,6 +352,28 @@ export default function SalesOrderPage() {
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
// 결재 상태 조인 (target_table='sales_order_mng', target_record_id = order_no)
|
||||
let approvalMap: Record<string, any> = {};
|
||||
if (orderNos.length > 0) {
|
||||
try {
|
||||
const apprRes = await apiClient.post(`/table-management/tables/approval_requests/data`, {
|
||||
page: 1, size: orderNos.length + 10,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "target_table", operator: "equals", value: "sales_order_mng" },
|
||||
{ columnName: "target_record_id", operator: "in", value: orderNos.map(String) },
|
||||
] },
|
||||
autoFilter: true,
|
||||
sort: { columnName: "request_id", order: "desc" },
|
||||
});
|
||||
const apprs = apprRes.data?.data?.data || apprRes.data?.data?.rows || [];
|
||||
// 같은 order_no에 여러 결재가 있으면 최신만 (sort desc 첫 번째)
|
||||
for (const a of apprs) {
|
||||
const rid = String(a.target_record_id);
|
||||
if (!approvalMap[rid]) approvalMap[rid] = a;
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
// part_code → item_info 조인
|
||||
const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))];
|
||||
let itemMap: Record<string, any> = {};
|
||||
@@ -359,6 +400,7 @@ export default function SalesOrderPage() {
|
||||
const item = itemMap[row.part_code];
|
||||
const master = masterMap[row.order_no];
|
||||
const rawUnit = row.unit || item?.inventory_unit || "";
|
||||
const appr = approvalMap[String(row.order_no)] || null;
|
||||
return {
|
||||
...row,
|
||||
part_name: row.part_name || item?.item_name || "",
|
||||
@@ -366,6 +408,8 @@ export default function SalesOrderPage() {
|
||||
material: row.material || (item ? (resolveLabel("item_material", item.material) || item.material || "") : ""),
|
||||
unit: resolveLabel("item_inventory_unit", rawUnit) || rawUnit,
|
||||
memo: row.memo || master?.memo || "",
|
||||
approval_status: appr?.status || "",
|
||||
approval_request_id: appr?.request_id || null,
|
||||
_master: master || {},
|
||||
};
|
||||
});
|
||||
@@ -381,6 +425,13 @@ export default function SalesOrderPage() {
|
||||
|
||||
useEffect(() => { fetchOrders(); }, [fetchOrders]);
|
||||
|
||||
// 결재 처리 완료 시 목록 새로고침
|
||||
useEffect(() => {
|
||||
const handler = () => fetchOrders();
|
||||
window.addEventListener("approval-processed", handler);
|
||||
return () => window.removeEventListener("approval-processed", handler);
|
||||
}, [fetchOrders]);
|
||||
|
||||
// 카테고리 코드→라벨 변환
|
||||
const resolveLabel = useCallback((key: string, code: string) => {
|
||||
if (!code) return "";
|
||||
@@ -705,19 +756,16 @@ export default function SalesOrderPage() {
|
||||
const filters: any[] = [];
|
||||
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
|
||||
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
|
||||
// 관리품목 필터: 다중값(콤마 구분) 저장된 경우도 매칭되도록 contains 사용
|
||||
if (itemSearchDivision !== "all") {
|
||||
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
|
||||
// 코드 또는 라벨이 저장된 경우 모두 조회하기 위해 in 연산자 사용
|
||||
const divValues = [itemSearchDivision];
|
||||
if (divLabel) divValues.push(divLabel);
|
||||
filters.push({ columnName: "division", operator: "in", value: divValues });
|
||||
filters.push({ columnName: "division", operator: "contains", value: itemSearchDivision });
|
||||
}
|
||||
|
||||
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
|
||||
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
|
||||
// 거래처우선 단가방식일 때 거래처 매핑 id 정규화 → 서버 필터 적용
|
||||
// price_mode의 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음)
|
||||
const priceModeLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || "";
|
||||
const isCustomerPrice = priceModeLabel.includes("거래처");
|
||||
const partnerId = masterForm.partner_id;
|
||||
let customerItemIds: Set<string> | null = null;
|
||||
|
||||
if (isCustomerPrice && partnerId) {
|
||||
try {
|
||||
@@ -727,7 +775,36 @@ export default function SalesOrderPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
|
||||
customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean));
|
||||
const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[];
|
||||
if (rawIds.length === 0) {
|
||||
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
|
||||
setItemSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
// UUID와 문자열(item_number) 분리
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const uuidIds = rawIds.filter(v => uuidRegex.test(v));
|
||||
const codeIds = rawIds.filter(v => !uuidRegex.test(v));
|
||||
|
||||
// 문자열(item_number)을 item_info에서 id로 변환
|
||||
let convertedIds: string[] = [];
|
||||
if (codeIds.length > 0) {
|
||||
const convRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: codeIds.length + 10,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || [];
|
||||
convertedIds = convRows.map((r: any) => r.id).filter(Boolean);
|
||||
}
|
||||
|
||||
const finalIds = [...new Set([...uuidIds, ...convertedIds])];
|
||||
if (finalIds.length === 0) {
|
||||
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
|
||||
setItemSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
filters.push({ columnName: "id", operator: "in", value: finalIds });
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
@@ -737,14 +814,9 @@ export default function SalesOrderPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
let rows = resData?.data || resData?.rows || [];
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const serverTotal = resData?.total || resData?.totalCount || rows.length;
|
||||
|
||||
// 거래처우선일 때 연결된 품목만 표시 (클라이언트 필터)
|
||||
if (customerItemIds) {
|
||||
rows = rows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
|
||||
}
|
||||
|
||||
setItemSearchResults(rows);
|
||||
setItemTotal(serverTotal);
|
||||
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
|
||||
@@ -778,8 +850,9 @@ export default function SalesOrderPage() {
|
||||
const selected = Array.from(itemSelectedMap.values());
|
||||
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
|
||||
|
||||
const isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ";
|
||||
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
|
||||
const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || "";
|
||||
const isStandardPrice = pmLabel.includes("기준");
|
||||
const isCustomerPrice = pmLabel.includes("거래처");
|
||||
const partnerId = masterForm.partner_id;
|
||||
|
||||
let customerPriceMap: Record<string, string> = {};
|
||||
@@ -847,10 +920,10 @@ export default function SalesOrderPage() {
|
||||
// 단가 재계산: 단가방식/거래처 변경 시 기존 품목 단가 갱신
|
||||
const recalcPrices = useCallback(async (priceMode: string, partnerId: string) => {
|
||||
if (detailRows.length === 0) return;
|
||||
const STANDARD_CODES = ["CAT_MM0BUZKL_HJ7U", "CAT_MLKG792S_54WJ"];
|
||||
const CUSTOMER_CODES = ["CAT_MM0BV3OS_41DX", "CAT_MLKG7D8K_N8SI"];
|
||||
const isStandard = STANDARD_CODES.includes(priceMode);
|
||||
const isCustomer = CUSTOMER_CODES.includes(priceMode);
|
||||
// price_mode 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음)
|
||||
const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === priceMode)?.label || "";
|
||||
const isStandard = pmLabel.includes("기준");
|
||||
const isCustomer = pmLabel.includes("거래처");
|
||||
|
||||
if (isStandard) {
|
||||
// 품목 기준단가 조회
|
||||
@@ -925,9 +998,11 @@ export default function SalesOrderPage() {
|
||||
setDetailRows((prev) => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
// 조건부 레이어 판단
|
||||
const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W";
|
||||
const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO";
|
||||
// 조건부 레이어 판단 (라벨 기반 — 카테고리 코드는 회사마다 다를 수 있음)
|
||||
const inputModeLabel = (categoryOptions["input_mode"] || []).find((o) => o.code === masterForm.input_mode)?.label || "";
|
||||
const sellModeLabel = (categoryOptions["sell_mode"] || []).find((o) => o.code === masterForm.sell_mode)?.label || "";
|
||||
const isSupplierFirst = inputModeLabel.includes("공급") || inputModeLabel.includes("거래처");
|
||||
const isOverseas = sellModeLabel.includes("해외") || sellModeLabel.includes("수출");
|
||||
|
||||
const handleExcelDownload = async () => {
|
||||
if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
|
||||
@@ -994,6 +1069,42 @@ export default function SalesOrderPage() {
|
||||
>
|
||||
<Trash2 className="w-4 h-4" /> 삭제{checkedIds.length > 0 && ` (${checkedIds.length})`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
className="text-primary border-primary/20 bg-primary/5 hover:bg-primary/10"
|
||||
disabled={checkedIds.length !== 1}
|
||||
onClick={() => {
|
||||
const item = orders.find((o) => o.id === checkedIds[0]);
|
||||
if (!item) return;
|
||||
// 이미 활성 결재가 있으면 차단 (재상신은 rejected/cancelled만 허용)
|
||||
const blockedStatuses = ["requested", "in_progress", "approved", "post_pending"];
|
||||
if (item.approval_status && blockedStatuses.includes(item.approval_status)) {
|
||||
const labelMap: Record<string, string> = {
|
||||
requested: "요청됨", in_progress: "결재중", approved: "승인완료", post_pending: "후결대기",
|
||||
};
|
||||
toast.error(`이미 ${labelMap[item.approval_status]} 상태의 결재가 존재합니다.`);
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("open-approval-modal", {
|
||||
detail: {
|
||||
targetTable: "sales_order_mng",
|
||||
targetRecordId: String(item.order_no),
|
||||
targetRecordData: {
|
||||
order_no: item.order_no,
|
||||
partner_id: item._master?.partner_id || item.partner_id,
|
||||
order_date: item.order_date,
|
||||
item_name: item.part_name,
|
||||
qty: item.qty,
|
||||
amount: item.amount,
|
||||
},
|
||||
defaultTitle: `수주결재: ${item.order_no} - ${item.part_name || ""}`,
|
||||
defaultDescription: `수주번호: ${item.order_no}\n품목: ${item.part_name || ""}\n수량: ${item.qty || 0}\n금액: ${Number(item.amount || 0).toLocaleString()}원`,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<ClipboardList className="w-4 h-4" /> 결재 상신
|
||||
</Button>
|
||||
<div className="h-5 w-px bg-border mx-0.5" />
|
||||
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-4 h-4" /> 엑셀 업로드
|
||||
@@ -1030,6 +1141,7 @@ export default function SalesOrderPage() {
|
||||
<col style={{ width: "90px" }} />
|
||||
<col style={{ width: "110px" }} />
|
||||
<col style={{ width: "100px" }} />
|
||||
<col style={{ width: "90px" }} />
|
||||
<col style={{ width: "120px" }} />
|
||||
</colgroup>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
@@ -1095,12 +1207,22 @@ export default function SalesOrderPage() {
|
||||
</TableRow>
|
||||
) : (
|
||||
ts.groupData(paginatedRows).map((row: any) => {
|
||||
// 그룹 헤더 행 렌더링
|
||||
if (row._isGroupHeader) {
|
||||
return (
|
||||
<TableRow key={`header-${row._groupValue}-${Math.random()}`} className="bg-primary/5 font-semibold border-t-2 border-primary/30">
|
||||
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
|
||||
📂 {row._groupValue} ({row._groupCount}건)
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
// 그룹 요약 행 렌더링
|
||||
if (row._isGroupSummary) {
|
||||
return (
|
||||
<TableRow key={`summary-${row._groupKey || Math.random()}`} className="bg-muted/60 font-semibold border-t border-primary/20">
|
||||
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
|
||||
{row._groupLabel || "합계"}: {row._count ? `${row._count}건` : ""}
|
||||
{row._groupValue || "합계"}
|
||||
{row.qty ? ` · 수량 ${Number(row.qty).toLocaleString()}` : ""}
|
||||
{row.amount ? ` · 금액 ${Number(row.amount).toLocaleString()}` : ""}
|
||||
</TableCell>
|
||||
@@ -1146,6 +1268,24 @@ export default function SalesOrderPage() {
|
||||
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
|
||||
<TableCell className="text-[13px]">{row.due_date || ""}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{row.approval_status && row.approval_request_id ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.dispatchEvent(new CustomEvent("open-approval-detail-modal", {
|
||||
detail: { requestId: row.approval_request_id },
|
||||
}));
|
||||
}}
|
||||
className={cn("inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold cursor-pointer hover:opacity-80 transition-opacity", APPROVAL_STATUS_CLASS[row.approval_status] || "bg-muted text-muted-foreground")}
|
||||
title="결재 상세보기"
|
||||
>
|
||||
{APPROVAL_STATUS_LABEL[row.approval_status] || row.approval_status}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40 text-[11px]">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.memo || ""}</span></TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -1475,6 +1615,9 @@ export default function SalesOrderPage() {
|
||||
</div>
|
||||
<Button size="sm" onClick={() => {
|
||||
setItemSelectedMap(new Map());
|
||||
setItemSearchResults([]);
|
||||
setItemTotal(0);
|
||||
setItemTotalPages(1);
|
||||
setItemPage(1);
|
||||
setItemPageInput("1");
|
||||
setItemSearchKeyword("");
|
||||
@@ -1680,7 +1823,16 @@ export default function SalesOrderPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{itemSearchResults.length === 0 ? (
|
||||
{itemSearchLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-12 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
<span className="text-xs text-muted-foreground">품목을 불러오는 중...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : itemSearchResults.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">검색 결과가 없어요</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -104,10 +104,10 @@ export default function InboundOutboundPage() {
|
||||
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
|
||||
if (itemCodes.length > 0) {
|
||||
try {
|
||||
// 단위 카테고리 코드→라벨 매핑 로드
|
||||
// 재고단위 카테고리 코드→라벨 매핑 로드
|
||||
let unitLabelMap: Record<string, string> = {};
|
||||
try {
|
||||
const catRes = await apiClient.get("/table-categories/item_info/unit/values");
|
||||
const catRes = await apiClient.get("/table-categories/item_info/inventory_unit/values");
|
||||
if (catRes.data?.success && catRes.data.data?.length > 0) {
|
||||
const flatten = (vals: any[]) => {
|
||||
for (const v of vals) {
|
||||
@@ -127,7 +127,7 @@ export default function InboundOutboundPage() {
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
const map: Record<string, { item_name: string; unit: string }> = {};
|
||||
for (const i of items) {
|
||||
const rawUnit = i.unit || "";
|
||||
const rawUnit = i.inventory_unit || "";
|
||||
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit };
|
||||
}
|
||||
setItemMap(map);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -54,8 +54,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { toast } from "sonner";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
@@ -284,7 +284,6 @@ export default function ReceivingPage() {
|
||||
const [modalMemo, setModalMemo] = useState("");
|
||||
const [selectedItems, setSelectedItems] = useState<SelectedSourceItem[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const savingLockRef = useRef(false);
|
||||
|
||||
// 수정 모드
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
@@ -390,7 +389,7 @@ export default function ReceivingPage() {
|
||||
const flatRows = useMemo(() => {
|
||||
return data.map((row) => ({
|
||||
...row,
|
||||
inbound_type: resolveInboundType(row.inbound_type),
|
||||
inbound_type: resolveInboundType((row as any).detail_inbound_type || row.inbound_type),
|
||||
source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "",
|
||||
}));
|
||||
}, [data]);
|
||||
@@ -597,7 +596,7 @@ export default function ReceivingPage() {
|
||||
setSelectedItems(
|
||||
grouped.map((g) => ({
|
||||
key: g.id,
|
||||
inbound_type: g.inbound_type || "",
|
||||
inbound_type: (g as any).detail_inbound_type || g.inbound_type || "",
|
||||
reference_number: g.reference_number || "",
|
||||
supplier_code: (g as any).supplier_code || "",
|
||||
supplier_name: g.supplier_name || "",
|
||||
@@ -637,7 +636,7 @@ export default function ReceivingPage() {
|
||||
setPurchaseOrders([]);
|
||||
setShipments([]);
|
||||
setItems([]);
|
||||
setSelectedItems([]);
|
||||
// 선택 품목은 유지 (여러 유형 혼합 가능)
|
||||
setSourcePage(1);
|
||||
setSourceTotalCount(0);
|
||||
loadSourceData(type, undefined, 1);
|
||||
@@ -653,7 +652,7 @@ export default function ReceivingPage() {
|
||||
...prev,
|
||||
{
|
||||
key,
|
||||
inbound_type: "구매입고",
|
||||
inbound_type: modalInboundType,
|
||||
reference_number: po.purchase_no,
|
||||
supplier_code: po.supplier_code,
|
||||
supplier_name: po.supplier_name,
|
||||
@@ -679,7 +678,7 @@ export default function ReceivingPage() {
|
||||
...prev,
|
||||
{
|
||||
key,
|
||||
inbound_type: "반품입고",
|
||||
inbound_type: modalInboundType,
|
||||
reference_number: sh.instruction_no,
|
||||
supplier_code: "",
|
||||
supplier_name: sh.partner_id,
|
||||
@@ -697,15 +696,15 @@ export default function ReceivingPage() {
|
||||
]);
|
||||
};
|
||||
|
||||
// 품목 추가
|
||||
// 품목 추가 (현재 선택된 입고유형 사용)
|
||||
const addItem = (item: ItemSource) => {
|
||||
const key = `item-${item.id}`;
|
||||
const key = `item-${item.id}-${modalInboundType}`;
|
||||
if (selectedItems.some((s) => s.key === key)) return;
|
||||
setSelectedItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key,
|
||||
inbound_type: "기타입고",
|
||||
inbound_type: modalInboundType,
|
||||
reference_number: item.item_number,
|
||||
supplier_code: "",
|
||||
supplier_name: "",
|
||||
@@ -752,7 +751,6 @@ export default function ReceivingPage() {
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (savingLockRef.current) return;
|
||||
if (selectedItems.length === 0) {
|
||||
alert("입고할 품목을 선택해주세요.");
|
||||
return;
|
||||
@@ -772,7 +770,6 @@ export default function ReceivingPage() {
|
||||
toast.error("창고를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
savingLockRef.current = true;
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editMode) {
|
||||
@@ -868,7 +865,6 @@ export default function ReceivingPage() {
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
savingLockRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1014,12 +1010,11 @@ export default function ReceivingPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedRows.map((row) => {
|
||||
paginatedRows.map((row, idx) => {
|
||||
const isChecked = checkedIds.includes(row.id);
|
||||
const rowKey = (row as any).detail_id ? `${row.id}-${(row as any).detail_id}` : row.id;
|
||||
return (
|
||||
<TableRow
|
||||
key={rowKey}
|
||||
key={`${row.id}-${idx}`}
|
||||
className={cn(
|
||||
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
|
||||
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
|
||||
@@ -1407,6 +1402,7 @@ export default function ReceivingPage() {
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[30px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
|
||||
<TableHead className="w-[70px] p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고유형</TableHead>
|
||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">참조번호</TableHead>
|
||||
<TableHead className="w-[80px] p-2 text-right">
|
||||
@@ -1427,6 +1423,9 @@ export default function ReceivingPage() {
|
||||
<TableCell className="p-2 text-center">
|
||||
{idx + 1}
|
||||
</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Badge variant="outline" className="text-[10px]">{item.inbound_type || modalInboundType}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px] p-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="truncate font-medium" title={item.item_name}>
|
||||
|
||||
@@ -1986,7 +1986,7 @@ export default function BomManagementPage() {
|
||||
|
||||
{/* ─── BOM 등록/수정 모달 ─────────────────── */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditMode ? "BOM 수정" : "BOM 등록"}</DialogTitle>
|
||||
<DialogDescription>{isEditMode ? "BOM 정보를 수정해요" : "새로운 BOM을 등록해요"}</DialogDescription>
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AddressSearchButton } from "@/components/common/AddressSearchButton";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -60,6 +62,9 @@ const SUPPLIER_GRID_COLUMNS = [
|
||||
{ key: "contact_person", label: "담당자" },
|
||||
{ key: "contact_phone", label: "전화번호" },
|
||||
{ key: "email", label: "이메일" },
|
||||
{ key: "bank_name", label: "은행" },
|
||||
{ key: "account_number", label: "계좌번호" },
|
||||
{ key: "remark", label: "비고" },
|
||||
{ key: "business_number", label: "사업자번호" },
|
||||
{ key: "address", label: "주소" },
|
||||
{ key: "status", label: "상태" },
|
||||
@@ -1289,6 +1294,9 @@ export default function SupplierManagementPage() {
|
||||
email: { width: "w-[160px]" },
|
||||
business_number: { width: "w-[120px]" },
|
||||
address: { minWidth: "min-w-[150px]" },
|
||||
bank_name: { width: "w-[100px]" },
|
||||
account_number: { width: "w-[140px]" },
|
||||
remark: { minWidth: "min-w-[120px]" },
|
||||
status: {
|
||||
width: "w-[70px]",
|
||||
render: (val: any) =>
|
||||
@@ -1898,13 +1906,48 @@ export default function SupplierManagementPage() {
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-sm">주소</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={supplierForm.address || ""}
|
||||
onChange={(e) => setSupplierForm((p) => ({ ...p, address: e.target.value }))}
|
||||
placeholder="주소 검색 버튼으로 빠르게 입력"
|
||||
className="h-9 flex-1"
|
||||
/>
|
||||
<AddressSearchButton
|
||||
className="h-9 shrink-0"
|
||||
onComplete={(data) => {
|
||||
setSupplierForm((p) => ({ ...p, address: data.address }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">은행</Label>
|
||||
<Input
|
||||
value={supplierForm.address || ""}
|
||||
onChange={(e) => setSupplierForm((p) => ({ ...p, address: e.target.value }))}
|
||||
placeholder="주소"
|
||||
value={supplierForm.bank_name || ""}
|
||||
onChange={(e) => setSupplierForm((p) => ({ ...p, bank_name: e.target.value }))}
|
||||
placeholder="예: 국민은행"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">계좌번호</Label>
|
||||
<Input
|
||||
value={supplierForm.account_number || ""}
|
||||
onChange={(e) => setSupplierForm((p) => ({ ...p, account_number: e.target.value }))}
|
||||
placeholder="예: 123-456-789012"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-sm">비고</Label>
|
||||
<Textarea
|
||||
value={supplierForm.remark || ""}
|
||||
onChange={(e) => setSupplierForm((p) => ({ ...p, remark: e.target.value }))}
|
||||
placeholder="비고"
|
||||
className="min-h-[70px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 세금유형 */}
|
||||
|
||||
@@ -606,7 +606,7 @@ export default function InspectionManagementPage() {
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-col gap-3 p-3 h-[calc(100vh-4rem)] overflow-auto">
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<div className="bg-card rounded-lg border">
|
||||
|
||||
@@ -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";
|
||||
@@ -34,12 +36,6 @@ const GRID_COLUMNS = [
|
||||
{ key: "is_active", label: "사용여부" },
|
||||
];
|
||||
|
||||
const INSPECTION_TYPES = [
|
||||
{ key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] },
|
||||
{ key: "outgoing_inspection", label: "출하검사", matchLabels: ["출하검사", "출고검사", "출하", "출고"] },
|
||||
{ key: "process_inspection", label: "공정검사", matchLabels: ["공정검사", "공정"] },
|
||||
{ key: "final_inspection", label: "최종검사", matchLabels: ["최종검사", "최종", "완제품검사"] },
|
||||
] as const;
|
||||
|
||||
type InspectionRow = {
|
||||
id: string;
|
||||
@@ -79,6 +75,15 @@ export default function ItemInspectionInfoPage() {
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
|
||||
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
// 검사유형 목록 (검사기준 카테고리 기반)
|
||||
const INSPECTION_TYPES = useMemo(() => {
|
||||
return inspTypeCatOptions.map((cat) => ({
|
||||
key: cat.code,
|
||||
label: cat.label,
|
||||
matchLabels: [cat.code, cat.label],
|
||||
}));
|
||||
}, [inspTypeCatOptions]);
|
||||
const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [judgmentCatOptions, setJudgmentCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspUnitCatOptions, setInspUnitCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
@@ -96,6 +101,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 +478,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 +729,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 +887,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);
|
||||
@@ -752,11 +995,11 @@ export default function ItemInspectionInfoPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{editMode ? "품목검사정보 수정" : "품목검사정보 등록"}</DialogTitle>
|
||||
<DialogDescription className="sr-only">품목검사정보를 등록합니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
|
||||
{/* 품목 정보 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">품목 정보</h4>
|
||||
@@ -918,7 +1161,7 @@ export default function ItemInspectionInfoPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}저장
|
||||
@@ -930,6 +1173,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AddressSearchButton } from "@/components/common/AddressSearchButton";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -60,6 +62,9 @@ const CUSTOMER_GRID_COLUMNS = [
|
||||
{ key: "contact_person", label: "담당자" },
|
||||
{ key: "contact_phone", label: "전화번호" },
|
||||
{ key: "email", label: "이메일" },
|
||||
{ key: "bank_name", label: "은행" },
|
||||
{ key: "account_number", label: "계좌번호" },
|
||||
{ key: "remark", label: "비고" },
|
||||
{ key: "business_number", label: "사업자번호" },
|
||||
{ key: "address", label: "주소" },
|
||||
{ key: "status", label: "상태" },
|
||||
@@ -1309,6 +1314,9 @@ export default function CustomerManagementPage() {
|
||||
email: { width: "w-[160px]" },
|
||||
business_number: { width: "w-[120px]" },
|
||||
address: { minWidth: "min-w-[150px]" },
|
||||
bank_name: { width: "w-[100px]" },
|
||||
account_number: { width: "w-[140px]" },
|
||||
remark: { minWidth: "min-w-[120px]" },
|
||||
status: {
|
||||
width: "w-[70px]",
|
||||
render: (val: any) =>
|
||||
@@ -1889,13 +1897,48 @@ export default function CustomerManagementPage() {
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-sm">주소</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={customerForm.address || ""}
|
||||
onChange={(e) => setCustomerForm((p) => ({ ...p, address: e.target.value }))}
|
||||
placeholder="주소 검색 버튼으로 빠르게 입력"
|
||||
className="h-9 flex-1"
|
||||
/>
|
||||
<AddressSearchButton
|
||||
className="h-9 shrink-0"
|
||||
onComplete={(data) => {
|
||||
setCustomerForm((p) => ({ ...p, address: data.address }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">은행</Label>
|
||||
<Input
|
||||
value={customerForm.address || ""}
|
||||
onChange={(e) => setCustomerForm((p) => ({ ...p, address: e.target.value }))}
|
||||
placeholder="주소"
|
||||
value={customerForm.bank_name || ""}
|
||||
onChange={(e) => setCustomerForm((p) => ({ ...p, bank_name: e.target.value }))}
|
||||
placeholder="예: 국민은행"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">계좌번호</Label>
|
||||
<Input
|
||||
value={customerForm.account_number || ""}
|
||||
onChange={(e) => setCustomerForm((p) => ({ ...p, account_number: e.target.value }))}
|
||||
placeholder="예: 123-456-789012"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-sm">비고</Label>
|
||||
<Textarea
|
||||
value={customerForm.remark || ""}
|
||||
onChange={(e) => setCustomerForm((p) => ({ ...p, remark: e.target.value }))}
|
||||
placeholder="비고"
|
||||
className="min-h-[70px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 세금유형 */}
|
||||
|
||||
@@ -58,6 +58,7 @@ const FLAT_COLUMNS = [
|
||||
{ key: "unit_price", label: "단가", source: "detail" },
|
||||
{ key: "amount", label: "금액", source: "detail" },
|
||||
{ key: "due_date", label: "납기일", source: "detail" },
|
||||
{ key: "approval_status", label: "결재상태", source: "master" },
|
||||
{ key: "memo", label: "메모", source: "master" },
|
||||
];
|
||||
|
||||
@@ -66,8 +67,26 @@ const DETAIL_HEADER_COLS = FLAT_COLUMNS.filter((c) => c.source === "detail");
|
||||
// 필터용 전체 키
|
||||
const GRID_COLUMNS_CONFIG = FLAT_COLUMNS.map(({ key, label }) => ({ key, label }));
|
||||
|
||||
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(14) = 15
|
||||
const TOTAL_COLS = 15;
|
||||
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(15) = 16
|
||||
const TOTAL_COLS = 16;
|
||||
|
||||
// 결재상태 라벨/색상
|
||||
const APPROVAL_STATUS_LABEL: Record<string, string> = {
|
||||
requested: "요청",
|
||||
in_progress: "결재중",
|
||||
approved: "승인완료",
|
||||
rejected: "반려",
|
||||
cancelled: "회수",
|
||||
post_pending: "후결대기",
|
||||
};
|
||||
const APPROVAL_STATUS_CLASS: Record<string, string> = {
|
||||
requested: "bg-secondary text-secondary-foreground",
|
||||
in_progress: "bg-primary/10 text-primary border border-primary/20",
|
||||
approved: "bg-emerald-500/10 text-emerald-600 border border-emerald-500/20",
|
||||
rejected: "bg-destructive/10 text-destructive border border-destructive/20",
|
||||
cancelled: "bg-muted text-muted-foreground",
|
||||
post_pending: "bg-warning/10 text-warning",
|
||||
};
|
||||
|
||||
// 헤더 필터 Popover
|
||||
function HeaderFilterPopover({
|
||||
@@ -333,6 +352,28 @@ export default function SalesOrderPage() {
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
// 결재 상태 조인 (target_table='sales_order_mng', target_record_id = order_no)
|
||||
let approvalMap: Record<string, any> = {};
|
||||
if (orderNos.length > 0) {
|
||||
try {
|
||||
const apprRes = await apiClient.post(`/table-management/tables/approval_requests/data`, {
|
||||
page: 1, size: orderNos.length + 10,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "target_table", operator: "equals", value: "sales_order_mng" },
|
||||
{ columnName: "target_record_id", operator: "in", value: orderNos.map(String) },
|
||||
] },
|
||||
autoFilter: true,
|
||||
sort: { columnName: "request_id", order: "desc" },
|
||||
});
|
||||
const apprs = apprRes.data?.data?.data || apprRes.data?.data?.rows || [];
|
||||
// 같은 order_no에 여러 결재가 있으면 최신만 (sort desc 첫 번째)
|
||||
for (const a of apprs) {
|
||||
const rid = String(a.target_record_id);
|
||||
if (!approvalMap[rid]) approvalMap[rid] = a;
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
// part_code → item_info 조인
|
||||
const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))];
|
||||
let itemMap: Record<string, any> = {};
|
||||
@@ -359,6 +400,7 @@ export default function SalesOrderPage() {
|
||||
const item = itemMap[row.part_code];
|
||||
const master = masterMap[row.order_no];
|
||||
const rawUnit = row.unit || item?.inventory_unit || "";
|
||||
const appr = approvalMap[String(row.order_no)] || null;
|
||||
return {
|
||||
...row,
|
||||
part_name: row.part_name || item?.item_name || "",
|
||||
@@ -366,6 +408,8 @@ export default function SalesOrderPage() {
|
||||
material: row.material || (item ? (resolveLabel("item_material", item.material) || item.material || "") : ""),
|
||||
unit: resolveLabel("item_inventory_unit", rawUnit) || rawUnit,
|
||||
memo: row.memo || master?.memo || "",
|
||||
approval_status: appr?.status || "",
|
||||
approval_request_id: appr?.request_id || null,
|
||||
_master: master || {},
|
||||
};
|
||||
});
|
||||
@@ -381,6 +425,13 @@ export default function SalesOrderPage() {
|
||||
|
||||
useEffect(() => { fetchOrders(); }, [fetchOrders]);
|
||||
|
||||
// 결재 처리 완료 시 목록 새로고침
|
||||
useEffect(() => {
|
||||
const handler = () => fetchOrders();
|
||||
window.addEventListener("approval-processed", handler);
|
||||
return () => window.removeEventListener("approval-processed", handler);
|
||||
}, [fetchOrders]);
|
||||
|
||||
// 카테고리 코드→라벨 변환
|
||||
const resolveLabel = useCallback((key: string, code: string) => {
|
||||
if (!code) return "";
|
||||
@@ -705,19 +756,16 @@ export default function SalesOrderPage() {
|
||||
const filters: any[] = [];
|
||||
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
|
||||
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
|
||||
// 관리품목 필터: 다중값(콤마 구분) 저장된 경우도 매칭되도록 contains 사용
|
||||
if (itemSearchDivision !== "all") {
|
||||
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
|
||||
// 코드 또는 라벨이 저장된 경우 모두 조회하기 위해 in 연산자 사용
|
||||
const divValues = [itemSearchDivision];
|
||||
if (divLabel) divValues.push(divLabel);
|
||||
filters.push({ columnName: "division", operator: "in", value: divValues });
|
||||
filters.push({ columnName: "division", operator: "contains", value: itemSearchDivision });
|
||||
}
|
||||
|
||||
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
|
||||
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
|
||||
// 거래처우선 단가방식일 때 거래처 매핑 id 정규화 → 서버 필터 적용
|
||||
// price_mode의 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음)
|
||||
const priceModeLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || "";
|
||||
const isCustomerPrice = priceModeLabel.includes("거래처");
|
||||
const partnerId = masterForm.partner_id;
|
||||
let customerItemIds: Set<string> | null = null;
|
||||
|
||||
if (isCustomerPrice && partnerId) {
|
||||
try {
|
||||
@@ -727,7 +775,36 @@ export default function SalesOrderPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
|
||||
customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean));
|
||||
const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[];
|
||||
if (rawIds.length === 0) {
|
||||
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
|
||||
setItemSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
// UUID와 문자열(item_number) 분리
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const uuidIds = rawIds.filter(v => uuidRegex.test(v));
|
||||
const codeIds = rawIds.filter(v => !uuidRegex.test(v));
|
||||
|
||||
// 문자열(item_number)을 item_info에서 id로 변환
|
||||
let convertedIds: string[] = [];
|
||||
if (codeIds.length > 0) {
|
||||
const convRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: codeIds.length + 10,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || [];
|
||||
convertedIds = convRows.map((r: any) => r.id).filter(Boolean);
|
||||
}
|
||||
|
||||
const finalIds = [...new Set([...uuidIds, ...convertedIds])];
|
||||
if (finalIds.length === 0) {
|
||||
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
|
||||
setItemSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
filters.push({ columnName: "id", operator: "in", value: finalIds });
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
@@ -737,14 +814,9 @@ export default function SalesOrderPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
let rows = resData?.data || resData?.rows || [];
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const serverTotal = resData?.total || resData?.totalCount || rows.length;
|
||||
|
||||
// 거래처우선일 때 연결된 품목만 표시 (클라이언트 필터)
|
||||
if (customerItemIds) {
|
||||
rows = rows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
|
||||
}
|
||||
|
||||
setItemSearchResults(rows);
|
||||
setItemTotal(serverTotal);
|
||||
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
|
||||
@@ -778,8 +850,9 @@ export default function SalesOrderPage() {
|
||||
const selected = Array.from(itemSelectedMap.values());
|
||||
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
|
||||
|
||||
const isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ";
|
||||
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
|
||||
const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || "";
|
||||
const isStandardPrice = pmLabel.includes("기준");
|
||||
const isCustomerPrice = pmLabel.includes("거래처");
|
||||
const partnerId = masterForm.partner_id;
|
||||
|
||||
let customerPriceMap: Record<string, string> = {};
|
||||
@@ -847,10 +920,10 @@ export default function SalesOrderPage() {
|
||||
// 단가 재계산: 단가방식/거래처 변경 시 기존 품목 단가 갱신
|
||||
const recalcPrices = useCallback(async (priceMode: string, partnerId: string) => {
|
||||
if (detailRows.length === 0) return;
|
||||
const STANDARD_CODES = ["CAT_MM0BUZKL_HJ7U", "CAT_MLKG792S_54WJ"];
|
||||
const CUSTOMER_CODES = ["CAT_MM0BV3OS_41DX", "CAT_MLKG7D8K_N8SI"];
|
||||
const isStandard = STANDARD_CODES.includes(priceMode);
|
||||
const isCustomer = CUSTOMER_CODES.includes(priceMode);
|
||||
// price_mode 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음)
|
||||
const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === priceMode)?.label || "";
|
||||
const isStandard = pmLabel.includes("기준");
|
||||
const isCustomer = pmLabel.includes("거래처");
|
||||
|
||||
if (isStandard) {
|
||||
// 품목 기준단가 조회
|
||||
@@ -925,9 +998,11 @@ export default function SalesOrderPage() {
|
||||
setDetailRows((prev) => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
// 조건부 레이어 판단
|
||||
const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W";
|
||||
const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO";
|
||||
// 조건부 레이어 판단 (라벨 기반 — 카테고리 코드는 회사마다 다를 수 있음)
|
||||
const inputModeLabel = (categoryOptions["input_mode"] || []).find((o) => o.code === masterForm.input_mode)?.label || "";
|
||||
const sellModeLabel = (categoryOptions["sell_mode"] || []).find((o) => o.code === masterForm.sell_mode)?.label || "";
|
||||
const isSupplierFirst = inputModeLabel.includes("공급") || inputModeLabel.includes("거래처");
|
||||
const isOverseas = sellModeLabel.includes("해외") || sellModeLabel.includes("수출");
|
||||
|
||||
const handleExcelDownload = async () => {
|
||||
if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
|
||||
@@ -994,6 +1069,42 @@ export default function SalesOrderPage() {
|
||||
>
|
||||
<Trash2 className="w-4 h-4" /> 삭제{checkedIds.length > 0 && ` (${checkedIds.length})`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
className="text-primary border-primary/20 bg-primary/5 hover:bg-primary/10"
|
||||
disabled={checkedIds.length !== 1}
|
||||
onClick={() => {
|
||||
const item = orders.find((o) => o.id === checkedIds[0]);
|
||||
if (!item) return;
|
||||
// 이미 활성 결재가 있으면 차단 (재상신은 rejected/cancelled만 허용)
|
||||
const blockedStatuses = ["requested", "in_progress", "approved", "post_pending"];
|
||||
if (item.approval_status && blockedStatuses.includes(item.approval_status)) {
|
||||
const labelMap: Record<string, string> = {
|
||||
requested: "요청됨", in_progress: "결재중", approved: "승인완료", post_pending: "후결대기",
|
||||
};
|
||||
toast.error(`이미 ${labelMap[item.approval_status]} 상태의 결재가 존재합니다.`);
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("open-approval-modal", {
|
||||
detail: {
|
||||
targetTable: "sales_order_mng",
|
||||
targetRecordId: String(item.order_no),
|
||||
targetRecordData: {
|
||||
order_no: item.order_no,
|
||||
partner_id: item._master?.partner_id || item.partner_id,
|
||||
order_date: item.order_date,
|
||||
item_name: item.part_name,
|
||||
qty: item.qty,
|
||||
amount: item.amount,
|
||||
},
|
||||
defaultTitle: `수주결재: ${item.order_no} - ${item.part_name || ""}`,
|
||||
defaultDescription: `수주번호: ${item.order_no}\n품목: ${item.part_name || ""}\n수량: ${item.qty || 0}\n금액: ${Number(item.amount || 0).toLocaleString()}원`,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<ClipboardList className="w-4 h-4" /> 결재 상신
|
||||
</Button>
|
||||
<div className="h-5 w-px bg-border mx-0.5" />
|
||||
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-4 h-4" /> 엑셀 업로드
|
||||
@@ -1030,6 +1141,7 @@ export default function SalesOrderPage() {
|
||||
<col style={{ width: "90px" }} />
|
||||
<col style={{ width: "110px" }} />
|
||||
<col style={{ width: "100px" }} />
|
||||
<col style={{ width: "90px" }} />
|
||||
<col style={{ width: "120px" }} />
|
||||
</colgroup>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
@@ -1095,12 +1207,22 @@ export default function SalesOrderPage() {
|
||||
</TableRow>
|
||||
) : (
|
||||
ts.groupData(paginatedRows).map((row: any) => {
|
||||
// 그룹 헤더 행 렌더링
|
||||
if (row._isGroupHeader) {
|
||||
return (
|
||||
<TableRow key={`header-${row._groupValue}-${Math.random()}`} className="bg-primary/5 font-semibold border-t-2 border-primary/30">
|
||||
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
|
||||
📂 {row._groupValue} ({row._groupCount}건)
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
// 그룹 요약 행 렌더링
|
||||
if (row._isGroupSummary) {
|
||||
return (
|
||||
<TableRow key={`summary-${row._groupKey || Math.random()}`} className="bg-muted/60 font-semibold border-t border-primary/20">
|
||||
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
|
||||
{row._groupLabel || "합계"}: {row._count ? `${row._count}건` : ""}
|
||||
{row._groupValue || "합계"}
|
||||
{row.qty ? ` · 수량 ${Number(row.qty).toLocaleString()}` : ""}
|
||||
{row.amount ? ` · 금액 ${Number(row.amount).toLocaleString()}` : ""}
|
||||
</TableCell>
|
||||
@@ -1146,6 +1268,24 @@ export default function SalesOrderPage() {
|
||||
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
|
||||
<TableCell className="text-[13px]">{row.due_date || ""}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{row.approval_status && row.approval_request_id ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.dispatchEvent(new CustomEvent("open-approval-detail-modal", {
|
||||
detail: { requestId: row.approval_request_id },
|
||||
}));
|
||||
}}
|
||||
className={cn("inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold cursor-pointer hover:opacity-80 transition-opacity", APPROVAL_STATUS_CLASS[row.approval_status] || "bg-muted text-muted-foreground")}
|
||||
title="결재 상세보기"
|
||||
>
|
||||
{APPROVAL_STATUS_LABEL[row.approval_status] || row.approval_status}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40 text-[11px]">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.memo || ""}</span></TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -1475,6 +1615,9 @@ export default function SalesOrderPage() {
|
||||
</div>
|
||||
<Button size="sm" onClick={() => {
|
||||
setItemSelectedMap(new Map());
|
||||
setItemSearchResults([]);
|
||||
setItemTotal(0);
|
||||
setItemTotalPages(1);
|
||||
setItemPage(1);
|
||||
setItemPageInput("1");
|
||||
setItemSearchKeyword("");
|
||||
@@ -1680,7 +1823,16 @@ export default function SalesOrderPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{itemSearchResults.length === 0 ? (
|
||||
{itemSearchLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-12 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
<span className="text-xs text-muted-foreground">품목을 불러오는 중...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : itemSearchResults.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">검색 결과가 없어요</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -104,10 +104,10 @@ export default function InboundOutboundPage() {
|
||||
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
|
||||
if (itemCodes.length > 0) {
|
||||
try {
|
||||
// 단위 카테고리 코드→라벨 매핑 로드
|
||||
// 재고단위 카테고리 코드→라벨 매핑 로드
|
||||
let unitLabelMap: Record<string, string> = {};
|
||||
try {
|
||||
const catRes = await apiClient.get("/table-categories/item_info/unit/values");
|
||||
const catRes = await apiClient.get("/table-categories/item_info/inventory_unit/values");
|
||||
if (catRes.data?.success && catRes.data.data?.length > 0) {
|
||||
const flatten = (vals: any[]) => {
|
||||
for (const v of vals) {
|
||||
@@ -127,7 +127,7 @@ export default function InboundOutboundPage() {
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
const map: Record<string, { item_name: string; unit: string }> = {};
|
||||
for (const i of items) {
|
||||
const rawUnit = i.unit || "";
|
||||
const rawUnit = i.inventory_unit || "";
|
||||
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit };
|
||||
}
|
||||
setItemMap(map);
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
@@ -388,7 +389,7 @@ export default function ReceivingPage() {
|
||||
const flatRows = useMemo(() => {
|
||||
return data.map((row) => ({
|
||||
...row,
|
||||
inbound_type: resolveInboundType(row.inbound_type),
|
||||
inbound_type: resolveInboundType((row as any).detail_inbound_type || row.inbound_type),
|
||||
source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "",
|
||||
}));
|
||||
}, [data]);
|
||||
@@ -595,7 +596,7 @@ export default function ReceivingPage() {
|
||||
setSelectedItems(
|
||||
grouped.map((g) => ({
|
||||
key: g.id,
|
||||
inbound_type: g.inbound_type || "",
|
||||
inbound_type: (g as any).detail_inbound_type || g.inbound_type || "",
|
||||
reference_number: g.reference_number || "",
|
||||
supplier_code: (g as any).supplier_code || "",
|
||||
supplier_name: g.supplier_name || "",
|
||||
@@ -635,7 +636,7 @@ export default function ReceivingPage() {
|
||||
setPurchaseOrders([]);
|
||||
setShipments([]);
|
||||
setItems([]);
|
||||
setSelectedItems([]);
|
||||
// 선택 품목은 유지 (여러 유형 혼합 가능)
|
||||
setSourcePage(1);
|
||||
setSourceTotalCount(0);
|
||||
loadSourceData(type, undefined, 1);
|
||||
@@ -651,7 +652,7 @@ export default function ReceivingPage() {
|
||||
...prev,
|
||||
{
|
||||
key,
|
||||
inbound_type: "구매입고",
|
||||
inbound_type: modalInboundType,
|
||||
reference_number: po.purchase_no,
|
||||
supplier_code: po.supplier_code,
|
||||
supplier_name: po.supplier_name,
|
||||
@@ -677,7 +678,7 @@ export default function ReceivingPage() {
|
||||
...prev,
|
||||
{
|
||||
key,
|
||||
inbound_type: "반품입고",
|
||||
inbound_type: modalInboundType,
|
||||
reference_number: sh.instruction_no,
|
||||
supplier_code: "",
|
||||
supplier_name: sh.partner_id,
|
||||
@@ -695,15 +696,15 @@ export default function ReceivingPage() {
|
||||
]);
|
||||
};
|
||||
|
||||
// 품목 추가
|
||||
// 품목 추가 (현재 선택된 입고유형 사용)
|
||||
const addItem = (item: ItemSource) => {
|
||||
const key = `item-${item.id}`;
|
||||
const key = `item-${item.id}-${modalInboundType}`;
|
||||
if (selectedItems.some((s) => s.key === key)) return;
|
||||
setSelectedItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key,
|
||||
inbound_type: "기타입고",
|
||||
inbound_type: modalInboundType,
|
||||
reference_number: item.item_number,
|
||||
supplier_code: "",
|
||||
supplier_name: "",
|
||||
@@ -1009,11 +1010,11 @@ export default function ReceivingPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedRows.map((row) => {
|
||||
paginatedRows.map((row, idx) => {
|
||||
const isChecked = checkedIds.includes(row.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
key={`${row.id}-${idx}`}
|
||||
className={cn(
|
||||
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
|
||||
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
|
||||
@@ -1401,6 +1402,7 @@ export default function ReceivingPage() {
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[30px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
|
||||
<TableHead className="w-[70px] p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고유형</TableHead>
|
||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">참조번호</TableHead>
|
||||
<TableHead className="w-[80px] p-2 text-right">
|
||||
@@ -1421,6 +1423,9 @@ export default function ReceivingPage() {
|
||||
<TableCell className="p-2 text-center">
|
||||
{idx + 1}
|
||||
</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Badge variant="outline" className="text-[10px]">{item.inbound_type || modalInboundType}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px] p-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="truncate font-medium" title={item.item_name}>
|
||||
|
||||
@@ -1986,7 +1986,7 @@ export default function BomManagementPage() {
|
||||
|
||||
{/* ─── BOM 등록/수정 모달 ─────────────────── */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditMode ? "BOM 수정" : "BOM 등록"}</DialogTitle>
|
||||
<DialogDescription>{isEditMode ? "BOM 정보를 수정해요" : "새로운 BOM을 등록해요"}</DialogDescription>
|
||||
|
||||
@@ -606,7 +606,7 @@ export default function InspectionManagementPage() {
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-col gap-3 p-3 h-[calc(100vh-4rem)] overflow-auto">
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<div className="bg-card rounded-lg border">
|
||||
|
||||
@@ -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";
|
||||
@@ -34,12 +36,6 @@ const GRID_COLUMNS = [
|
||||
{ key: "is_active", label: "사용여부" },
|
||||
];
|
||||
|
||||
const INSPECTION_TYPES = [
|
||||
{ key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] },
|
||||
{ key: "outgoing_inspection", label: "출하검사", matchLabels: ["출하검사", "출고검사", "출하", "출고"] },
|
||||
{ key: "process_inspection", label: "공정검사", matchLabels: ["공정검사", "공정"] },
|
||||
{ key: "final_inspection", label: "최종검사", matchLabels: ["최종검사", "최종", "완제품검사"] },
|
||||
] as const;
|
||||
|
||||
type InspectionRow = {
|
||||
id: string;
|
||||
@@ -79,6 +75,15 @@ export default function ItemInspectionInfoPage() {
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
|
||||
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
// 검사유형 목록 (검사기준 카테고리 기반)
|
||||
const INSPECTION_TYPES = useMemo(() => {
|
||||
return inspTypeCatOptions.map((cat) => ({
|
||||
key: cat.code,
|
||||
label: cat.label,
|
||||
matchLabels: [cat.code, cat.label],
|
||||
}));
|
||||
}, [inspTypeCatOptions]);
|
||||
const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [judgmentCatOptions, setJudgmentCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspUnitCatOptions, setInspUnitCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
@@ -96,6 +101,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 +478,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 +729,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 +887,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);
|
||||
@@ -752,11 +995,11 @@ export default function ItemInspectionInfoPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{editMode ? "품목검사정보 수정" : "품목검사정보 등록"}</DialogTitle>
|
||||
<DialogDescription className="sr-only">품목검사정보를 등록합니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
|
||||
{/* 품목 정보 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">품목 정보</h4>
|
||||
@@ -918,7 +1161,7 @@ export default function ItemInspectionInfoPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}저장
|
||||
@@ -930,6 +1173,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ const FLAT_COLUMNS = [
|
||||
{ key: "unit_price", label: "단가", source: "detail" },
|
||||
{ key: "amount", label: "금액", source: "detail" },
|
||||
{ key: "due_date", label: "납기일", source: "detail" },
|
||||
{ key: "approval_status", label: "결재상태", source: "master" },
|
||||
{ key: "memo", label: "메모", source: "master" },
|
||||
];
|
||||
|
||||
@@ -66,8 +67,26 @@ const DETAIL_HEADER_COLS = FLAT_COLUMNS.filter((c) => c.source === "detail");
|
||||
// 필터용 전체 키
|
||||
const GRID_COLUMNS_CONFIG = FLAT_COLUMNS.map(({ key, label }) => ({ key, label }));
|
||||
|
||||
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(14) = 15
|
||||
const TOTAL_COLS = 15;
|
||||
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(15) = 16
|
||||
const TOTAL_COLS = 16;
|
||||
|
||||
// 결재상태 라벨/색상
|
||||
const APPROVAL_STATUS_LABEL: Record<string, string> = {
|
||||
requested: "요청",
|
||||
in_progress: "결재중",
|
||||
approved: "승인완료",
|
||||
rejected: "반려",
|
||||
cancelled: "회수",
|
||||
post_pending: "후결대기",
|
||||
};
|
||||
const APPROVAL_STATUS_CLASS: Record<string, string> = {
|
||||
requested: "bg-secondary text-secondary-foreground",
|
||||
in_progress: "bg-primary/10 text-primary border border-primary/20",
|
||||
approved: "bg-emerald-500/10 text-emerald-600 border border-emerald-500/20",
|
||||
rejected: "bg-destructive/10 text-destructive border border-destructive/20",
|
||||
cancelled: "bg-muted text-muted-foreground",
|
||||
post_pending: "bg-warning/10 text-warning",
|
||||
};
|
||||
|
||||
// 헤더 필터 Popover
|
||||
function HeaderFilterPopover({
|
||||
@@ -333,6 +352,28 @@ export default function SalesOrderPage() {
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
// 결재 상태 조인 (target_table='sales_order_mng', target_record_id = order_no)
|
||||
let approvalMap: Record<string, any> = {};
|
||||
if (orderNos.length > 0) {
|
||||
try {
|
||||
const apprRes = await apiClient.post(`/table-management/tables/approval_requests/data`, {
|
||||
page: 1, size: orderNos.length + 10,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "target_table", operator: "equals", value: "sales_order_mng" },
|
||||
{ columnName: "target_record_id", operator: "in", value: orderNos.map(String) },
|
||||
] },
|
||||
autoFilter: true,
|
||||
sort: { columnName: "request_id", order: "desc" },
|
||||
});
|
||||
const apprs = apprRes.data?.data?.data || apprRes.data?.data?.rows || [];
|
||||
// 같은 order_no에 여러 결재가 있으면 최신만 (sort desc 첫 번째)
|
||||
for (const a of apprs) {
|
||||
const rid = String(a.target_record_id);
|
||||
if (!approvalMap[rid]) approvalMap[rid] = a;
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
// part_code → item_info 조인
|
||||
const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))];
|
||||
let itemMap: Record<string, any> = {};
|
||||
@@ -359,6 +400,7 @@ export default function SalesOrderPage() {
|
||||
const item = itemMap[row.part_code];
|
||||
const master = masterMap[row.order_no];
|
||||
const rawUnit = row.unit || item?.inventory_unit || "";
|
||||
const appr = approvalMap[String(row.order_no)] || null;
|
||||
return {
|
||||
...row,
|
||||
part_name: row.part_name || item?.item_name || "",
|
||||
@@ -366,6 +408,8 @@ export default function SalesOrderPage() {
|
||||
material: row.material || (item ? (resolveLabel("item_material", item.material) || item.material || "") : ""),
|
||||
unit: resolveLabel("item_inventory_unit", rawUnit) || rawUnit,
|
||||
memo: row.memo || master?.memo || "",
|
||||
approval_status: appr?.status || "",
|
||||
approval_request_id: appr?.request_id || null,
|
||||
_master: master || {},
|
||||
};
|
||||
});
|
||||
@@ -381,6 +425,13 @@ export default function SalesOrderPage() {
|
||||
|
||||
useEffect(() => { fetchOrders(); }, [fetchOrders]);
|
||||
|
||||
// 결재 처리 완료 시 목록 새로고침
|
||||
useEffect(() => {
|
||||
const handler = () => fetchOrders();
|
||||
window.addEventListener("approval-processed", handler);
|
||||
return () => window.removeEventListener("approval-processed", handler);
|
||||
}, [fetchOrders]);
|
||||
|
||||
// 카테고리 코드→라벨 변환
|
||||
const resolveLabel = useCallback((key: string, code: string) => {
|
||||
if (!code) return "";
|
||||
@@ -705,19 +756,16 @@ export default function SalesOrderPage() {
|
||||
const filters: any[] = [];
|
||||
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
|
||||
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
|
||||
// 관리품목 필터: 다중값(콤마 구분) 저장된 경우도 매칭되도록 contains 사용
|
||||
if (itemSearchDivision !== "all") {
|
||||
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
|
||||
// 코드 또는 라벨이 저장된 경우 모두 조회하기 위해 in 연산자 사용
|
||||
const divValues = [itemSearchDivision];
|
||||
if (divLabel) divValues.push(divLabel);
|
||||
filters.push({ columnName: "division", operator: "in", value: divValues });
|
||||
filters.push({ columnName: "division", operator: "contains", value: itemSearchDivision });
|
||||
}
|
||||
|
||||
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
|
||||
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
|
||||
// 거래처우선 단가방식일 때 거래처 매핑 id 정규화 → 서버 필터 적용
|
||||
// price_mode의 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음)
|
||||
const priceModeLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || "";
|
||||
const isCustomerPrice = priceModeLabel.includes("거래처");
|
||||
const partnerId = masterForm.partner_id;
|
||||
let customerItemIds: Set<string> | null = null;
|
||||
|
||||
if (isCustomerPrice && partnerId) {
|
||||
try {
|
||||
@@ -727,7 +775,36 @@ export default function SalesOrderPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
|
||||
customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean));
|
||||
const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[];
|
||||
if (rawIds.length === 0) {
|
||||
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
|
||||
setItemSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
// UUID와 문자열(item_number) 분리
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const uuidIds = rawIds.filter(v => uuidRegex.test(v));
|
||||
const codeIds = rawIds.filter(v => !uuidRegex.test(v));
|
||||
|
||||
// 문자열(item_number)을 item_info에서 id로 변환
|
||||
let convertedIds: string[] = [];
|
||||
if (codeIds.length > 0) {
|
||||
const convRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: codeIds.length + 10,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || [];
|
||||
convertedIds = convRows.map((r: any) => r.id).filter(Boolean);
|
||||
}
|
||||
|
||||
const finalIds = [...new Set([...uuidIds, ...convertedIds])];
|
||||
if (finalIds.length === 0) {
|
||||
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
|
||||
setItemSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
filters.push({ columnName: "id", operator: "in", value: finalIds });
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
@@ -737,14 +814,9 @@ export default function SalesOrderPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
let rows = resData?.data || resData?.rows || [];
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const serverTotal = resData?.total || resData?.totalCount || rows.length;
|
||||
|
||||
// 거래처우선일 때 연결된 품목만 표시 (클라이언트 필터)
|
||||
if (customerItemIds) {
|
||||
rows = rows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
|
||||
}
|
||||
|
||||
setItemSearchResults(rows);
|
||||
setItemTotal(serverTotal);
|
||||
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
|
||||
@@ -778,8 +850,9 @@ export default function SalesOrderPage() {
|
||||
const selected = Array.from(itemSelectedMap.values());
|
||||
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
|
||||
|
||||
const isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ";
|
||||
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
|
||||
const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || "";
|
||||
const isStandardPrice = pmLabel.includes("기준");
|
||||
const isCustomerPrice = pmLabel.includes("거래처");
|
||||
const partnerId = masterForm.partner_id;
|
||||
|
||||
let customerPriceMap: Record<string, string> = {};
|
||||
@@ -847,10 +920,10 @@ export default function SalesOrderPage() {
|
||||
// 단가 재계산: 단가방식/거래처 변경 시 기존 품목 단가 갱신
|
||||
const recalcPrices = useCallback(async (priceMode: string, partnerId: string) => {
|
||||
if (detailRows.length === 0) return;
|
||||
const STANDARD_CODES = ["CAT_MM0BUZKL_HJ7U", "CAT_MLKG792S_54WJ"];
|
||||
const CUSTOMER_CODES = ["CAT_MM0BV3OS_41DX", "CAT_MLKG7D8K_N8SI"];
|
||||
const isStandard = STANDARD_CODES.includes(priceMode);
|
||||
const isCustomer = CUSTOMER_CODES.includes(priceMode);
|
||||
// price_mode 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음)
|
||||
const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === priceMode)?.label || "";
|
||||
const isStandard = pmLabel.includes("기준");
|
||||
const isCustomer = pmLabel.includes("거래처");
|
||||
|
||||
if (isStandard) {
|
||||
// 품목 기준단가 조회
|
||||
@@ -925,9 +998,11 @@ export default function SalesOrderPage() {
|
||||
setDetailRows((prev) => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
// 조건부 레이어 판단
|
||||
const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W";
|
||||
const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO";
|
||||
// 조건부 레이어 판단 (라벨 기반 — 카테고리 코드는 회사마다 다를 수 있음)
|
||||
const inputModeLabel = (categoryOptions["input_mode"] || []).find((o) => o.code === masterForm.input_mode)?.label || "";
|
||||
const sellModeLabel = (categoryOptions["sell_mode"] || []).find((o) => o.code === masterForm.sell_mode)?.label || "";
|
||||
const isSupplierFirst = inputModeLabel.includes("공급") || inputModeLabel.includes("거래처");
|
||||
const isOverseas = sellModeLabel.includes("해외") || sellModeLabel.includes("수출");
|
||||
|
||||
const handleExcelDownload = async () => {
|
||||
if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
|
||||
@@ -994,6 +1069,42 @@ export default function SalesOrderPage() {
|
||||
>
|
||||
<Trash2 className="w-4 h-4" /> 삭제{checkedIds.length > 0 && ` (${checkedIds.length})`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
className="text-primary border-primary/20 bg-primary/5 hover:bg-primary/10"
|
||||
disabled={checkedIds.length !== 1}
|
||||
onClick={() => {
|
||||
const item = orders.find((o) => o.id === checkedIds[0]);
|
||||
if (!item) return;
|
||||
// 이미 활성 결재가 있으면 차단 (재상신은 rejected/cancelled만 허용)
|
||||
const blockedStatuses = ["requested", "in_progress", "approved", "post_pending"];
|
||||
if (item.approval_status && blockedStatuses.includes(item.approval_status)) {
|
||||
const labelMap: Record<string, string> = {
|
||||
requested: "요청됨", in_progress: "결재중", approved: "승인완료", post_pending: "후결대기",
|
||||
};
|
||||
toast.error(`이미 ${labelMap[item.approval_status]} 상태의 결재가 존재합니다.`);
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("open-approval-modal", {
|
||||
detail: {
|
||||
targetTable: "sales_order_mng",
|
||||
targetRecordId: String(item.order_no),
|
||||
targetRecordData: {
|
||||
order_no: item.order_no,
|
||||
partner_id: item._master?.partner_id || item.partner_id,
|
||||
order_date: item.order_date,
|
||||
item_name: item.part_name,
|
||||
qty: item.qty,
|
||||
amount: item.amount,
|
||||
},
|
||||
defaultTitle: `수주결재: ${item.order_no} - ${item.part_name || ""}`,
|
||||
defaultDescription: `수주번호: ${item.order_no}\n품목: ${item.part_name || ""}\n수량: ${item.qty || 0}\n금액: ${Number(item.amount || 0).toLocaleString()}원`,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<ClipboardList className="w-4 h-4" /> 결재 상신
|
||||
</Button>
|
||||
<div className="h-5 w-px bg-border mx-0.5" />
|
||||
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-4 h-4" /> 엑셀 업로드
|
||||
@@ -1030,6 +1141,7 @@ export default function SalesOrderPage() {
|
||||
<col style={{ width: "90px" }} />
|
||||
<col style={{ width: "110px" }} />
|
||||
<col style={{ width: "100px" }} />
|
||||
<col style={{ width: "90px" }} />
|
||||
<col style={{ width: "120px" }} />
|
||||
</colgroup>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
@@ -1095,12 +1207,22 @@ export default function SalesOrderPage() {
|
||||
</TableRow>
|
||||
) : (
|
||||
ts.groupData(paginatedRows).map((row: any) => {
|
||||
// 그룹 헤더 행 렌더링
|
||||
if (row._isGroupHeader) {
|
||||
return (
|
||||
<TableRow key={`header-${row._groupValue}-${Math.random()}`} className="bg-primary/5 font-semibold border-t-2 border-primary/30">
|
||||
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
|
||||
📂 {row._groupValue} ({row._groupCount}건)
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
// 그룹 요약 행 렌더링
|
||||
if (row._isGroupSummary) {
|
||||
return (
|
||||
<TableRow key={`summary-${row._groupKey || Math.random()}`} className="bg-muted/60 font-semibold border-t border-primary/20">
|
||||
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
|
||||
{row._groupLabel || "합계"}: {row._count ? `${row._count}건` : ""}
|
||||
{row._groupValue || "합계"}
|
||||
{row.qty ? ` · 수량 ${Number(row.qty).toLocaleString()}` : ""}
|
||||
{row.amount ? ` · 금액 ${Number(row.amount).toLocaleString()}` : ""}
|
||||
</TableCell>
|
||||
@@ -1146,6 +1268,24 @@ export default function SalesOrderPage() {
|
||||
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
|
||||
<TableCell className="text-[13px]">{row.due_date || ""}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{row.approval_status && row.approval_request_id ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.dispatchEvent(new CustomEvent("open-approval-detail-modal", {
|
||||
detail: { requestId: row.approval_request_id },
|
||||
}));
|
||||
}}
|
||||
className={cn("inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold cursor-pointer hover:opacity-80 transition-opacity", APPROVAL_STATUS_CLASS[row.approval_status] || "bg-muted text-muted-foreground")}
|
||||
title="결재 상세보기"
|
||||
>
|
||||
{APPROVAL_STATUS_LABEL[row.approval_status] || row.approval_status}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40 text-[11px]">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.memo || ""}</span></TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -1475,6 +1615,9 @@ export default function SalesOrderPage() {
|
||||
</div>
|
||||
<Button size="sm" onClick={() => {
|
||||
setItemSelectedMap(new Map());
|
||||
setItemSearchResults([]);
|
||||
setItemTotal(0);
|
||||
setItemTotalPages(1);
|
||||
setItemPage(1);
|
||||
setItemPageInput("1");
|
||||
setItemSearchKeyword("");
|
||||
@@ -1680,7 +1823,16 @@ export default function SalesOrderPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{itemSearchResults.length === 0 ? (
|
||||
{itemSearchLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-12 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
<span className="text-xs text-muted-foreground">품목을 불러오는 중...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : itemSearchResults.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">검색 결과가 없어요</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function InboundOutboundPage() {
|
||||
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 품목명/단위 캐시
|
||||
const [itemMap, setItemMap] = useState<Record<string, { item_name: string; unit: string; width: string; height: string; thickness: string }>>({});
|
||||
const [itemMap, setItemMap] = useState<Record<string, { item_name: string; unit: string }>>({});
|
||||
const [warehouseMap, setWarehouseMap] = useState<Record<string, string>>({});
|
||||
const [userMap, setUserMap] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -104,10 +104,10 @@ export default function InboundOutboundPage() {
|
||||
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
|
||||
if (itemCodes.length > 0) {
|
||||
try {
|
||||
// 단위 카테고리 코드→라벨 매핑 로드
|
||||
// 재고단위 카테고리 코드→라벨 매핑 로드
|
||||
let unitLabelMap: Record<string, string> = {};
|
||||
try {
|
||||
const catRes = await apiClient.get("/table-categories/item_info/unit/values");
|
||||
const catRes = await apiClient.get("/table-categories/item_info/inventory_unit/values");
|
||||
if (catRes.data?.success && catRes.data.data?.length > 0) {
|
||||
const flatten = (vals: any[]) => {
|
||||
for (const v of vals) {
|
||||
@@ -125,16 +125,10 @@ export default function InboundOutboundPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
const map: Record<string, { item_name: string; unit: string; width: string; height: string; thickness: string }> = {};
|
||||
const map: Record<string, { item_name: string; unit: string }> = {};
|
||||
for (const i of items) {
|
||||
const rawUnit = i.unit || "";
|
||||
if (!map[i.item_number]) map[i.item_number] = {
|
||||
item_name: i.item_name || "",
|
||||
unit: unitLabelMap[rawUnit] || rawUnit,
|
||||
width: i.width || "",
|
||||
height: i.height || "",
|
||||
thickness: i.thickness || "",
|
||||
};
|
||||
const rawUnit = i.inventory_unit || "";
|
||||
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit };
|
||||
}
|
||||
setItemMap(map);
|
||||
} catch { /* skip */ }
|
||||
@@ -347,9 +341,6 @@ export default function InboundOutboundPage() {
|
||||
<TableHead className="w-[80px] text-center text-[11px]">위치</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px]">품목코드</TableHead>
|
||||
<TableHead className="w-[160px] text-[11px]">품목명</TableHead>
|
||||
<TableHead className="w-[60px] text-right text-[11px]">가로</TableHead>
|
||||
<TableHead className="w-[60px] text-right text-[11px]">세로</TableHead>
|
||||
<TableHead className="w-[60px] text-right text-[11px]">두께</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px]">수량</TableHead>
|
||||
<TableHead className="w-[50px] text-center text-[11px]">단위</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px]">로트번호</TableHead>
|
||||
@@ -370,7 +361,6 @@ export default function InboundOutboundPage() {
|
||||
<Badge variant="outline" className="text-[10px]">{row._count}건</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell colSpan={3} />
|
||||
<TableCell className="text-right font-mono font-bold text-primary text-[13px]">
|
||||
{fmtNum(row._totalQty)}
|
||||
</TableCell>
|
||||
@@ -414,9 +404,6 @@ export default function InboundOutboundPage() {
|
||||
<TableCell className="text-center text-[12px]">{row.location_code || "-"}</TableCell>
|
||||
<TableCell className="text-[12px] font-mono">{row.item_code || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{info?.item_name || "-"}</TableCell>
|
||||
<TableCell className="text-right text-[12px] font-mono text-muted-foreground">{info?.width || "-"}</TableCell>
|
||||
<TableCell className="text-right text-[12px] font-mono text-muted-foreground">{info?.height || "-"}</TableCell>
|
||||
<TableCell className="text-right text-[12px] font-mono text-muted-foreground">{info?.thickness || "-"}</TableCell>
|
||||
<TableCell className={cn("text-right font-mono font-semibold text-[13px]", isIn ? "text-emerald-600" : "text-amber-600")}>
|
||||
{isIn ? "+" : ""}{fmtNum(qty)}
|
||||
</TableCell>
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { toast } from "sonner";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
@@ -117,9 +118,6 @@ const GRID_COLUMNS = [
|
||||
{ key: "supplier_name", label: "공급처" },
|
||||
{ key: "item_number", label: "품목코드" },
|
||||
{ key: "item_name", label: "품목명" },
|
||||
{ key: "width", label: "가로" },
|
||||
{ key: "height", label: "세로" },
|
||||
{ key: "thickness", label: "두께" },
|
||||
{ key: "spec", label: "규격" },
|
||||
{ key: "inbound_qty", label: "입고수량" },
|
||||
{ key: "unit_price", label: "단가" },
|
||||
@@ -129,8 +127,8 @@ const GRID_COLUMNS = [
|
||||
{ key: "remark", label: "비고" },
|
||||
];
|
||||
|
||||
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(18) = 19
|
||||
const TOTAL_COLS = 19;
|
||||
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16
|
||||
const TOTAL_COLS = 16;
|
||||
|
||||
// 헤더 필터 Popover
|
||||
function HeaderFilterPopover({
|
||||
@@ -422,7 +420,7 @@ export default function ReceivingPage() {
|
||||
const flatRows = useMemo(() => {
|
||||
return data.map((row) => ({
|
||||
...row,
|
||||
inbound_type: resolveInboundType(row.inbound_type),
|
||||
inbound_type: resolveInboundType((row as any).detail_inbound_type || row.inbound_type),
|
||||
source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "",
|
||||
}));
|
||||
}, [data]);
|
||||
@@ -631,7 +629,7 @@ export default function ReceivingPage() {
|
||||
setSelectedItems(
|
||||
grouped.map((g) => ({
|
||||
key: g.id,
|
||||
inbound_type: g.inbound_type || "",
|
||||
inbound_type: (g as any).detail_inbound_type || g.inbound_type || "",
|
||||
reference_number: g.reference_number || "",
|
||||
supplier_code: (g as any).supplier_code || "",
|
||||
supplier_name: g.supplier_name || "",
|
||||
@@ -671,7 +669,7 @@ export default function ReceivingPage() {
|
||||
setPurchaseOrders([]);
|
||||
setShipments([]);
|
||||
setItems([]);
|
||||
setSelectedItems([]);
|
||||
// 선택 품목은 유지 (여러 유형 혼합 가능)
|
||||
setSourcePage(1);
|
||||
setSourceTotalCount(0);
|
||||
loadSourceData(type, undefined, 1);
|
||||
@@ -687,16 +685,13 @@ export default function ReceivingPage() {
|
||||
...prev,
|
||||
{
|
||||
key,
|
||||
inbound_type: "구매입고",
|
||||
inbound_type: modalInboundType,
|
||||
reference_number: po.purchase_no,
|
||||
supplier_code: po.supplier_code,
|
||||
supplier_name: po.supplier_name,
|
||||
item_number: po.item_code,
|
||||
item_name: po.item_name,
|
||||
spec: po.spec || "",
|
||||
width: (po as any).width || "",
|
||||
height: (po as any).height || "",
|
||||
thickness: (po as any).thickness || "",
|
||||
material: po.material || "",
|
||||
unit: "EA",
|
||||
inbound_qty: po.remain_qty,
|
||||
@@ -716,16 +711,13 @@ export default function ReceivingPage() {
|
||||
...prev,
|
||||
{
|
||||
key,
|
||||
inbound_type: "반품입고",
|
||||
inbound_type: modalInboundType,
|
||||
reference_number: sh.instruction_no,
|
||||
supplier_code: "",
|
||||
supplier_name: sh.partner_id,
|
||||
item_number: sh.item_code,
|
||||
item_name: sh.item_name,
|
||||
spec: sh.spec || "",
|
||||
width: (sh as any).width || "",
|
||||
height: (sh as any).height || "",
|
||||
thickness: (sh as any).thickness || "",
|
||||
material: sh.material || "",
|
||||
unit: "EA",
|
||||
inbound_qty: sh.ship_qty,
|
||||
@@ -737,24 +729,21 @@ export default function ReceivingPage() {
|
||||
]);
|
||||
};
|
||||
|
||||
// 품목 추가
|
||||
// 품목 추가 (현재 선택된 입고유형 사용)
|
||||
const addItem = (item: ItemSource) => {
|
||||
const key = `item-${item.id}`;
|
||||
const key = `item-${item.id}-${modalInboundType}`;
|
||||
if (selectedItems.some((s) => s.key === key)) return;
|
||||
setSelectedItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key,
|
||||
inbound_type: "기타입고",
|
||||
inbound_type: modalInboundType,
|
||||
reference_number: item.item_number,
|
||||
supplier_code: "",
|
||||
supplier_name: "",
|
||||
item_number: item.item_number,
|
||||
item_name: item.item_name,
|
||||
spec: item.spec || "",
|
||||
width: (item as any).width || "",
|
||||
height: (item as any).height || "",
|
||||
thickness: (item as any).thickness || "",
|
||||
material: item.material || "",
|
||||
unit: item.inventory_unit || "EA",
|
||||
inbound_qty: 0,
|
||||
@@ -984,9 +973,6 @@ export default function ReceivingPage() {
|
||||
<col style={{ width: "110px" }} />
|
||||
<col style={{ width: "100px" }} />
|
||||
<col style={{ width: "140px" }} />
|
||||
<col style={{ width: "70px" }} />
|
||||
<col style={{ width: "70px" }} />
|
||||
<col style={{ width: "70px" }} />
|
||||
<col style={{ width: "90px" }} />
|
||||
<col style={{ width: "80px" }} />
|
||||
<col style={{ width: "80px" }} />
|
||||
@@ -1057,11 +1043,11 @@ export default function ReceivingPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedRows.map((row) => {
|
||||
paginatedRows.map((row, idx) => {
|
||||
const isChecked = checkedIds.includes(row.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
key={`${row.id}-${idx}`}
|
||||
className={cn(
|
||||
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
|
||||
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
|
||||
@@ -1098,9 +1084,6 @@ export default function ReceivingPage() {
|
||||
<TableCell className="text-[13px] truncate max-w-[110px]"><span className="block truncate">{row.supplier_name || ""}</span></TableCell>
|
||||
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
|
||||
<TableCell className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-muted-foreground">{(row as any).width || "-"}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-muted-foreground">{(row as any).height || "-"}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-muted-foreground">{(row as any).thickness || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
|
||||
@@ -1452,6 +1435,7 @@ export default function ReceivingPage() {
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[30px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
|
||||
<TableHead className="w-[70px] p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고유형</TableHead>
|
||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">참조번호</TableHead>
|
||||
<TableHead className="w-[80px] p-2 text-right">
|
||||
@@ -1472,7 +1456,10 @@ export default function ReceivingPage() {
|
||||
<TableCell className="p-2 text-center">
|
||||
{idx + 1}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[220px] p-2">
|
||||
<TableCell className="p-2">
|
||||
<Badge variant="outline" className="text-[10px]">{item.inbound_type || modalInboundType}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px] p-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="truncate font-medium" title={item.item_name}>
|
||||
{item.item_name}
|
||||
@@ -1481,13 +1468,6 @@ export default function ReceivingPage() {
|
||||
{item.item_number}
|
||||
{item.spec ? ` | ${item.spec}` : ""}
|
||||
</span>
|
||||
{((item as any).width || (item as any).height || (item as any).thickness) && (
|
||||
<span className="text-muted-foreground truncate text-[10px]">
|
||||
{(item as any).width && `W ${(item as any).width}`}
|
||||
{(item as any).height && ` × H ${(item as any).height}`}
|
||||
{(item as any).thickness && ` × T ${(item as any).thickness}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="p-2 text-[11px]">
|
||||
@@ -1653,20 +1633,13 @@ function SourcePurchaseOrderTable({
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[120px] truncate p-2 font-medium" title={po.purchase_no}>{po.purchase_no}</TableCell>
|
||||
<TableCell className="max-w-[120px] truncate p-2" title={po.supplier_name}>{po.supplier_name}</TableCell>
|
||||
<TableCell className="max-w-[220px] p-2">
|
||||
<TableCell className="max-w-[200px] p-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="truncate font-medium" title={po.item_name}>{po.item_name}</span>
|
||||
<span className="text-muted-foreground truncate text-[10px]" title={`${po.item_code}${po.spec ? ` | ${po.spec}` : ""}`}>
|
||||
{po.item_code}
|
||||
{po.spec ? ` | ${po.spec}` : ""}
|
||||
</span>
|
||||
{((po as any).width || (po as any).height || (po as any).thickness) && (
|
||||
<span className="text-muted-foreground truncate text-[10px]">
|
||||
{(po as any).width && `W ${(po as any).width}`}
|
||||
{(po as any).height && ` × H ${(po as any).height}`}
|
||||
{(po as any).thickness && ` × T ${(po as any).thickness}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="p-2 text-right">
|
||||
@@ -1744,20 +1717,13 @@ function SourceShipmentTable({
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[100px] truncate p-2" title={sh.partner_id}>{sh.partner_id}</TableCell>
|
||||
<TableCell className="max-w-[220px] p-2">
|
||||
<TableCell className="max-w-[200px] p-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="truncate font-medium" title={sh.item_name}>{sh.item_name}</span>
|
||||
<span className="text-muted-foreground truncate text-[10px]" title={`${sh.item_code}${sh.spec ? ` | ${sh.spec}` : ""}`}>
|
||||
{sh.item_code}
|
||||
{sh.spec ? ` | ${sh.spec}` : ""}
|
||||
</span>
|
||||
{((sh as any).width || (sh as any).height || (sh as any).thickness) && (
|
||||
<span className="text-muted-foreground truncate text-[10px]">
|
||||
{(sh as any).width && `W ${(sh as any).width}`}
|
||||
{(sh as any).height && ` × H ${(sh as any).height}`}
|
||||
{(sh as any).thickness && ` × T ${(sh as any).thickness}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="p-2 text-right font-semibold">
|
||||
@@ -1830,13 +1796,6 @@ function SourceItemTable({
|
||||
<span className="text-muted-foreground truncate text-[10px]" title={item.item_number}>
|
||||
{item.item_number}
|
||||
</span>
|
||||
{((item as any).width || (item as any).height || (item as any).thickness) && (
|
||||
<span className="text-muted-foreground truncate text-[10px]">
|
||||
{(item as any).width && `W ${(item as any).width}`}
|
||||
{(item as any).height && ` × H ${(item as any).height}`}
|
||||
{(item as any).thickness && ` × T ${(item as any).thickness}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[100px] truncate p-2" title={item.spec || "-"}>{item.spec || "-"}</TableCell>
|
||||
|
||||
@@ -1986,7 +1986,7 @@ export default function BomManagementPage() {
|
||||
|
||||
{/* ─── BOM 등록/수정 모달 ─────────────────── */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditMode ? "BOM 수정" : "BOM 등록"}</DialogTitle>
|
||||
<DialogDescription>{isEditMode ? "BOM 정보를 수정해요" : "새로운 BOM을 등록해요"}</DialogDescription>
|
||||
|
||||
@@ -606,7 +606,7 @@ export default function InspectionManagementPage() {
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-col gap-3 p-3 h-[calc(100vh-4rem)] overflow-auto">
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<div className="bg-card rounded-lg border">
|
||||
|
||||
@@ -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";
|
||||
@@ -34,12 +36,6 @@ const GRID_COLUMNS = [
|
||||
{ key: "is_active", label: "사용여부" },
|
||||
];
|
||||
|
||||
const INSPECTION_TYPES = [
|
||||
{ key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] },
|
||||
{ key: "outgoing_inspection", label: "출하검사", matchLabels: ["출하검사", "출고검사", "출하", "출고"] },
|
||||
{ key: "process_inspection", label: "공정검사", matchLabels: ["공정검사", "공정"] },
|
||||
{ key: "final_inspection", label: "최종검사", matchLabels: ["최종검사", "최종", "완제품검사"] },
|
||||
] as const;
|
||||
|
||||
type InspectionRow = {
|
||||
id: string;
|
||||
@@ -79,6 +75,15 @@ export default function ItemInspectionInfoPage() {
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
|
||||
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
// 검사유형 목록 (검사기준 카테고리 기반)
|
||||
const INSPECTION_TYPES = useMemo(() => {
|
||||
return inspTypeCatOptions.map((cat) => ({
|
||||
key: cat.code,
|
||||
label: cat.label,
|
||||
matchLabels: [cat.code, cat.label],
|
||||
}));
|
||||
}, [inspTypeCatOptions]);
|
||||
const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [judgmentCatOptions, setJudgmentCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspUnitCatOptions, setInspUnitCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
@@ -96,6 +101,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 +478,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 +729,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 +887,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);
|
||||
@@ -752,11 +995,11 @@ export default function ItemInspectionInfoPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{editMode ? "품목검사정보 수정" : "품목검사정보 등록"}</DialogTitle>
|
||||
<DialogDescription className="sr-only">품목검사정보를 등록합니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
|
||||
{/* 품목 정보 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">품목 정보</h4>
|
||||
@@ -918,7 +1161,7 @@ export default function ItemInspectionInfoPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}저장
|
||||
@@ -930,6 +1173,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
|
||||
ClipboardList, Package, Search, X, Settings2, GripVertical,
|
||||
ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
@@ -161,6 +162,12 @@ export default function ChunganSalesOrderPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
// 전체 통계 (서버에서 별도 집계)
|
||||
const [totalStats, setTotalStats] = useState<{ totalAmount: number; totalQty: number }>({ totalAmount: 0, totalQty: 0 });
|
||||
// 좌측 페이지네이션
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [pageSizeInput, setPageSizeInput] = useState("20");
|
||||
const [selectedOrderNo, setSelectedOrderNo] = useState<string | null>(null);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
|
||||
@@ -280,7 +287,7 @@ export default function ChunganSalesOrderPage() {
|
||||
}
|
||||
// 거래처
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, { page: 1, size: 500, autoFilter: true });
|
||||
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, { page: 1, size: 5000, autoFilter: true });
|
||||
const custs = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
optMap["partner_id"] = custs.map((c: any) => ({
|
||||
code: c.customer_code,
|
||||
@@ -310,7 +317,7 @@ export default function ChunganSalesOrderPage() {
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
// 수주 목록 조회 (마스터 기준 페치 → 디테일을 order_no로 합산)
|
||||
// 수주 목록 조회 (마스터 서버 페이징 → 디테일 조인)
|
||||
const fetchMasterOrders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -319,38 +326,37 @@ export default function ChunganSalesOrderPage() {
|
||||
operator: f.operator,
|
||||
value: f.value,
|
||||
}));
|
||||
// 1. 마스터 테이블 기준으로 500건 페치
|
||||
|
||||
// 1단계: 마스터 서버 페이징 조회
|
||||
const mRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: currentPage, size: pageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
sortBy: "order_no",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
const masters = mRes.data?.data?.data || mRes.data?.data?.rows || [];
|
||||
const serverTotal = mRes.data?.data?.total || mRes.data?.data?.totalCount || masters.length;
|
||||
setTotalCount(serverTotal);
|
||||
|
||||
// 2. 마스터의 order_no들로 디테일 전부 조회
|
||||
// 2단계: 해당 페이지 마스터의 order_no로 디테일 조회
|
||||
const orderNos = masters.map((m: any) => m.order_no).filter(Boolean);
|
||||
let details: any[] = [];
|
||||
let allRows: any[] = [];
|
||||
if (orderNos.length > 0) {
|
||||
try {
|
||||
const dRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1, size: orderNos.length * 20 + 100,
|
||||
page: 1, size: orderNos.length * 50,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "in", value: orderNos }] },
|
||||
autoFilter: true,
|
||||
sort: { columnName: "order_no", order: "desc" },
|
||||
});
|
||||
details = dRes.data?.data?.data || dRes.data?.data?.rows || [];
|
||||
allRows = dRes.data?.data?.data || dRes.data?.data?.rows || [];
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setAllDetails(details);
|
||||
setAllDetails(allRows);
|
||||
|
||||
// 3. order_no 기준 디테일 집계 맵
|
||||
const detailMap: Record<string, any[]> = {};
|
||||
for (const d of details) {
|
||||
if (!d.order_no) continue;
|
||||
if (!detailMap[d.order_no]) detailMap[d.order_no] = [];
|
||||
detailMap[d.order_no].push(d);
|
||||
}
|
||||
const masterMap: Record<string, any> = {};
|
||||
for (const m of masters) masterMap[m.order_no] = m;
|
||||
|
||||
// 거래처 코드 → 이름 변환
|
||||
const resolvePartner = (code: string) => {
|
||||
@@ -358,52 +364,91 @@ export default function ChunganSalesOrderPage() {
|
||||
return categoryOptions["partner_id"]?.find((o) => o.code === code)?.label?.split(" (")[0] || code;
|
||||
};
|
||||
|
||||
// 4. 마스터 1건 = 1행 (디테일 집계 값 포함)
|
||||
const list = masters.map((master: any) => {
|
||||
const ds = detailMap[master.order_no] || [];
|
||||
let total_qty = 0, total_ship_qty = 0, total_balance = 0, total_amount = 0;
|
||||
let due_date = "";
|
||||
for (const d of ds) {
|
||||
total_qty += parseFloat(d.qty) || 0;
|
||||
total_ship_qty += parseFloat(d.ship_qty) || 0;
|
||||
total_balance += parseFloat(d.balance_qty) || 0;
|
||||
total_amount += parseFloat(d.amount) || 0;
|
||||
if (d.due_date && (!due_date || d.due_date > due_date)) due_date = d.due_date;
|
||||
}
|
||||
return {
|
||||
id: master.id || `master_${master.order_no}`,
|
||||
order_no: master.order_no,
|
||||
partner_name: resolvePartner(master.partner_id),
|
||||
item_count: ds.length,
|
||||
total_qty,
|
||||
total_ship_qty,
|
||||
total_balance,
|
||||
total_amount,
|
||||
due_date,
|
||||
status: master.status || "",
|
||||
// order_no 기준 집계 (마스터 기반으로 생성, 디테일 누적)
|
||||
const grouped: Record<string, any> = {};
|
||||
for (const m of masters) {
|
||||
const no = m.order_no;
|
||||
if (!no) continue;
|
||||
grouped[no] = {
|
||||
id: `master_${no}`,
|
||||
order_no: no,
|
||||
partner_name: resolvePartner(m.partner_id),
|
||||
item_count: 0,
|
||||
total_qty: 0,
|
||||
total_ship_qty: 0,
|
||||
total_balance: 0,
|
||||
total_amount: 0,
|
||||
due_date: m.due_date || "",
|
||||
status: m.status || "",
|
||||
};
|
||||
});
|
||||
}
|
||||
// 디테일 기준 집계
|
||||
for (const row of allRows) {
|
||||
const no = row.order_no;
|
||||
if (!no || !grouped[no]) continue;
|
||||
const g = grouped[no];
|
||||
g.item_count += 1;
|
||||
g.total_qty += parseFloat(row.qty) || 0;
|
||||
g.total_ship_qty += parseFloat(row.ship_qty) || 0;
|
||||
g.total_balance += parseFloat(row.balance_qty) || 0;
|
||||
g.total_amount += parseFloat(row.amount) || 0;
|
||||
if (row.due_date && (!g.due_date || row.due_date > g.due_date)) g.due_date = row.due_date;
|
||||
}
|
||||
const list = Object.values(grouped);
|
||||
setMasterOrders(list);
|
||||
setTotalCount(list.length);
|
||||
|
||||
// 전체 통계: DB에서 직접 SUM (size:99999 전체 조회 대신)
|
||||
try {
|
||||
const aggRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/aggregate`, {
|
||||
columns: [
|
||||
{ column: "qty", func: "sum" },
|
||||
{ column: "amount", func: "sum" },
|
||||
],
|
||||
autoFilter: true,
|
||||
});
|
||||
if (aggRes.data?.success) {
|
||||
setTotalStats({
|
||||
totalQty: Number(aggRes.data.data?.sum_qty) || 0,
|
||||
totalAmount: Number(aggRes.data.data?.sum_amount) || 0,
|
||||
});
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} catch (err) {
|
||||
console.error("수주 조회 실패:", err);
|
||||
toast.error("수주 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters, categoryOptions]);
|
||||
}, [searchFilters, categoryOptions, currentPage, pageSize]);
|
||||
|
||||
useEffect(() => { fetchMasterOrders(); }, [fetchMasterOrders]);
|
||||
|
||||
// 통계
|
||||
const stats = useMemo(() => {
|
||||
let totalAmount = 0, totalQty = 0;
|
||||
for (const m of masterOrders) {
|
||||
totalAmount += m.total_amount || 0;
|
||||
totalQty += m.total_qty || 0;
|
||||
// 서버 페이징 계산
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
const safePage = Math.min(Math.max(1, currentPage), totalPages);
|
||||
|
||||
const applyPageSize = () => {
|
||||
const n = parseInt(pageSizeInput, 10);
|
||||
if (!isNaN(n) && n >= 1) { setPageSize(n); setCurrentPage(1); }
|
||||
else setPageSizeInput(String(pageSize));
|
||||
};
|
||||
|
||||
const getPageNumbers = (): (number | "...")[] => {
|
||||
const pages: (number | "...")[] = [];
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (safePage > 3) pages.push("...");
|
||||
for (let i = Math.max(2, safePage - 1); i <= Math.min(totalPages - 1, safePage + 1); i++) pages.push(i);
|
||||
if (safePage < totalPages - 2) pages.push("...");
|
||||
pages.push(totalPages);
|
||||
}
|
||||
return { totalAmount, totalQty };
|
||||
}, [masterOrders]);
|
||||
return pages;
|
||||
};
|
||||
|
||||
// 통계 (전체 기준)
|
||||
const stats = totalStats;
|
||||
|
||||
// 우측: 선택된 수주 디테일 조회 (division 코드→라벨 변환)
|
||||
useEffect(() => {
|
||||
@@ -936,6 +981,7 @@ export default function ChunganSalesOrderPage() {
|
||||
data={masterOrders}
|
||||
loading={loading}
|
||||
showCheckbox
|
||||
showPagination={false}
|
||||
checkedIds={checkedIds}
|
||||
onCheckedChange={setCheckedIds}
|
||||
onRowClick={handleMasterRowClick}
|
||||
@@ -943,6 +989,38 @@ export default function ChunganSalesOrderPage() {
|
||||
tableName={MASTER_TABLE}
|
||||
emptyMessage="등록된 수주가 없습니다"
|
||||
/>
|
||||
{/* 페이지네이션 */}
|
||||
<div className="flex items-center justify-between border-t px-4 py-2 text-xs text-muted-foreground shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>전체 <span className="font-medium text-foreground">{totalCount}</span>건</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input type="number" min={1} value={pageSizeInput}
|
||||
onChange={(e) => setPageSizeInput(e.target.value)}
|
||||
onBlur={applyPageSize}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyPageSize(); } }}
|
||||
className="h-7 w-16 text-center text-xs" />
|
||||
<span>건씩</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => setCurrentPage(1)} disabled={safePage === 1} className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => setCurrentPage((p) => Math.max(1, p - 1))} disabled={safePage === 1} className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
|
||||
{getPageNumbers().map((page, idx) =>
|
||||
page === "..." ? (
|
||||
<span key={`dot-${idx}`} className="h-7 w-7 flex items-center justify-center text-muted-foreground">...</span>
|
||||
) : (
|
||||
<button key={page} onClick={() => setCurrentPage(page as number)}
|
||||
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||
page === safePage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>
|
||||
{page}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} disabled={safePage === totalPages} className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => setCurrentPage(totalPages)} disabled={safePage === totalPages} className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
<span>{safePage} / {totalPages}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
|
||||
@@ -104,10 +104,10 @@ export default function InboundOutboundPage() {
|
||||
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
|
||||
if (itemCodes.length > 0) {
|
||||
try {
|
||||
// 단위 카테고리 코드→라벨 매핑 로드
|
||||
// 재고단위 카테고리 코드→라벨 매핑 로드
|
||||
let unitLabelMap: Record<string, string> = {};
|
||||
try {
|
||||
const catRes = await apiClient.get("/table-categories/item_info/unit/values");
|
||||
const catRes = await apiClient.get("/table-categories/item_info/inventory_unit/values");
|
||||
if (catRes.data?.success && catRes.data.data?.length > 0) {
|
||||
const flatten = (vals: any[]) => {
|
||||
for (const v of vals) {
|
||||
@@ -127,7 +127,7 @@ export default function InboundOutboundPage() {
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
const map: Record<string, { item_name: string; unit: string }> = {};
|
||||
for (const i of items) {
|
||||
const rawUnit = i.unit || "";
|
||||
const rawUnit = i.inventory_unit || "";
|
||||
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit };
|
||||
}
|
||||
setItemMap(map);
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
@@ -388,7 +389,7 @@ export default function ReceivingPage() {
|
||||
const flatRows = useMemo(() => {
|
||||
return data.map((row) => ({
|
||||
...row,
|
||||
inbound_type: resolveInboundType(row.inbound_type),
|
||||
inbound_type: resolveInboundType((row as any).detail_inbound_type || row.inbound_type),
|
||||
source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "",
|
||||
}));
|
||||
}, [data]);
|
||||
@@ -595,7 +596,7 @@ export default function ReceivingPage() {
|
||||
setSelectedItems(
|
||||
grouped.map((g) => ({
|
||||
key: g.id,
|
||||
inbound_type: g.inbound_type || "",
|
||||
inbound_type: (g as any).detail_inbound_type || g.inbound_type || "",
|
||||
reference_number: g.reference_number || "",
|
||||
supplier_code: (g as any).supplier_code || "",
|
||||
supplier_name: g.supplier_name || "",
|
||||
@@ -635,7 +636,7 @@ export default function ReceivingPage() {
|
||||
setPurchaseOrders([]);
|
||||
setShipments([]);
|
||||
setItems([]);
|
||||
setSelectedItems([]);
|
||||
// 선택 품목은 유지 (여러 유형 혼합 가능)
|
||||
setSourcePage(1);
|
||||
setSourceTotalCount(0);
|
||||
loadSourceData(type, undefined, 1);
|
||||
@@ -651,7 +652,7 @@ export default function ReceivingPage() {
|
||||
...prev,
|
||||
{
|
||||
key,
|
||||
inbound_type: "구매입고",
|
||||
inbound_type: modalInboundType,
|
||||
reference_number: po.purchase_no,
|
||||
supplier_code: po.supplier_code,
|
||||
supplier_name: po.supplier_name,
|
||||
@@ -677,7 +678,7 @@ export default function ReceivingPage() {
|
||||
...prev,
|
||||
{
|
||||
key,
|
||||
inbound_type: "반품입고",
|
||||
inbound_type: modalInboundType,
|
||||
reference_number: sh.instruction_no,
|
||||
supplier_code: "",
|
||||
supplier_name: sh.partner_id,
|
||||
@@ -695,15 +696,15 @@ export default function ReceivingPage() {
|
||||
]);
|
||||
};
|
||||
|
||||
// 품목 추가
|
||||
// 품목 추가 (현재 선택된 입고유형 사용)
|
||||
const addItem = (item: ItemSource) => {
|
||||
const key = `item-${item.id}`;
|
||||
const key = `item-${item.id}-${modalInboundType}`;
|
||||
if (selectedItems.some((s) => s.key === key)) return;
|
||||
setSelectedItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key,
|
||||
inbound_type: "기타입고",
|
||||
inbound_type: modalInboundType,
|
||||
reference_number: item.item_number,
|
||||
supplier_code: "",
|
||||
supplier_name: "",
|
||||
@@ -1009,11 +1010,11 @@ export default function ReceivingPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedRows.map((row) => {
|
||||
paginatedRows.map((row, idx) => {
|
||||
const isChecked = checkedIds.includes(row.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
key={`${row.id}-${idx}`}
|
||||
className={cn(
|
||||
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
|
||||
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
|
||||
@@ -1401,6 +1402,7 @@ export default function ReceivingPage() {
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[30px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
|
||||
<TableHead className="w-[70px] p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고유형</TableHead>
|
||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">참조번호</TableHead>
|
||||
<TableHead className="w-[80px] p-2 text-right">
|
||||
@@ -1421,6 +1423,9 @@ export default function ReceivingPage() {
|
||||
<TableCell className="p-2 text-center">
|
||||
{idx + 1}
|
||||
</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Badge variant="outline" className="text-[10px]">{item.inbound_type || modalInboundType}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px] p-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="truncate font-medium" title={item.item_name}>
|
||||
|
||||
@@ -1986,7 +1986,7 @@ export default function BomManagementPage() {
|
||||
|
||||
{/* ─── BOM 등록/수정 모달 ─────────────────── */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditMode ? "BOM 수정" : "BOM 등록"}</DialogTitle>
|
||||
<DialogDescription>{isEditMode ? "BOM 정보를 수정해요" : "새로운 BOM을 등록해요"}</DialogDescription>
|
||||
|
||||
@@ -606,7 +606,7 @@ export default function InspectionManagementPage() {
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-col gap-3 p-3 h-[calc(100vh-4rem)] overflow-auto">
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<div className="bg-card rounded-lg border">
|
||||
|
||||
@@ -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";
|
||||
@@ -34,12 +36,6 @@ const GRID_COLUMNS = [
|
||||
{ key: "is_active", label: "사용여부" },
|
||||
];
|
||||
|
||||
const INSPECTION_TYPES = [
|
||||
{ key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] },
|
||||
{ key: "outgoing_inspection", label: "출하검사", matchLabels: ["출하검사", "출고검사", "출하", "출고"] },
|
||||
{ key: "process_inspection", label: "공정검사", matchLabels: ["공정검사", "공정"] },
|
||||
{ key: "final_inspection", label: "최종검사", matchLabels: ["최종검사", "최종", "완제품검사"] },
|
||||
] as const;
|
||||
|
||||
type InspectionRow = {
|
||||
id: string;
|
||||
@@ -79,6 +75,15 @@ export default function ItemInspectionInfoPage() {
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
|
||||
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
// 검사유형 목록 (검사기준 카테고리 기반)
|
||||
const INSPECTION_TYPES = useMemo(() => {
|
||||
return inspTypeCatOptions.map((cat) => ({
|
||||
key: cat.code,
|
||||
label: cat.label,
|
||||
matchLabels: [cat.code, cat.label],
|
||||
}));
|
||||
}, [inspTypeCatOptions]);
|
||||
const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [judgmentCatOptions, setJudgmentCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspUnitCatOptions, setInspUnitCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
@@ -96,6 +101,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 +478,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 +729,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 +887,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);
|
||||
@@ -752,11 +995,11 @@ export default function ItemInspectionInfoPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{editMode ? "품목검사정보 수정" : "품목검사정보 등록"}</DialogTitle>
|
||||
<DialogDescription className="sr-only">품목검사정보를 등록합니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
|
||||
{/* 품목 정보 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">품목 정보</h4>
|
||||
@@ -918,7 +1161,7 @@ export default function ItemInspectionInfoPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}저장
|
||||
@@ -930,6 +1173,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ const FLAT_COLUMNS = [
|
||||
{ key: "unit_price", label: "단가", source: "detail" },
|
||||
{ key: "amount", label: "금액", source: "detail" },
|
||||
{ key: "due_date", label: "납기일", source: "detail" },
|
||||
{ key: "approval_status", label: "결재상태", source: "master" },
|
||||
{ key: "memo", label: "메모", source: "master" },
|
||||
];
|
||||
|
||||
@@ -66,8 +67,26 @@ const DETAIL_HEADER_COLS = FLAT_COLUMNS.filter((c) => c.source === "detail");
|
||||
// 필터용 전체 키
|
||||
const GRID_COLUMNS_CONFIG = FLAT_COLUMNS.map(({ key, label }) => ({ key, label }));
|
||||
|
||||
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(14) = 15
|
||||
const TOTAL_COLS = 15;
|
||||
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(15) = 16
|
||||
const TOTAL_COLS = 16;
|
||||
|
||||
// 결재상태 라벨/색상
|
||||
const APPROVAL_STATUS_LABEL: Record<string, string> = {
|
||||
requested: "요청",
|
||||
in_progress: "결재중",
|
||||
approved: "승인완료",
|
||||
rejected: "반려",
|
||||
cancelled: "회수",
|
||||
post_pending: "후결대기",
|
||||
};
|
||||
const APPROVAL_STATUS_CLASS: Record<string, string> = {
|
||||
requested: "bg-secondary text-secondary-foreground",
|
||||
in_progress: "bg-primary/10 text-primary border border-primary/20",
|
||||
approved: "bg-emerald-500/10 text-emerald-600 border border-emerald-500/20",
|
||||
rejected: "bg-destructive/10 text-destructive border border-destructive/20",
|
||||
cancelled: "bg-muted text-muted-foreground",
|
||||
post_pending: "bg-warning/10 text-warning",
|
||||
};
|
||||
|
||||
// 헤더 필터 Popover
|
||||
function HeaderFilterPopover({
|
||||
@@ -333,6 +352,28 @@ export default function SalesOrderPage() {
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
// 결재 상태 조인 (target_table='sales_order_mng', target_record_id = order_no)
|
||||
let approvalMap: Record<string, any> = {};
|
||||
if (orderNos.length > 0) {
|
||||
try {
|
||||
const apprRes = await apiClient.post(`/table-management/tables/approval_requests/data`, {
|
||||
page: 1, size: orderNos.length + 10,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "target_table", operator: "equals", value: "sales_order_mng" },
|
||||
{ columnName: "target_record_id", operator: "in", value: orderNos.map(String) },
|
||||
] },
|
||||
autoFilter: true,
|
||||
sort: { columnName: "request_id", order: "desc" },
|
||||
});
|
||||
const apprs = apprRes.data?.data?.data || apprRes.data?.data?.rows || [];
|
||||
// 같은 order_no에 여러 결재가 있으면 최신만 (sort desc 첫 번째)
|
||||
for (const a of apprs) {
|
||||
const rid = String(a.target_record_id);
|
||||
if (!approvalMap[rid]) approvalMap[rid] = a;
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
// part_code → item_info 조인
|
||||
const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))];
|
||||
let itemMap: Record<string, any> = {};
|
||||
@@ -359,6 +400,7 @@ export default function SalesOrderPage() {
|
||||
const item = itemMap[row.part_code];
|
||||
const master = masterMap[row.order_no];
|
||||
const rawUnit = row.unit || item?.inventory_unit || "";
|
||||
const appr = approvalMap[String(row.order_no)] || null;
|
||||
return {
|
||||
...row,
|
||||
part_name: row.part_name || item?.item_name || "",
|
||||
@@ -366,6 +408,8 @@ export default function SalesOrderPage() {
|
||||
material: row.material || (item ? (resolveLabel("item_material", item.material) || item.material || "") : ""),
|
||||
unit: resolveLabel("item_inventory_unit", rawUnit) || rawUnit,
|
||||
memo: row.memo || master?.memo || "",
|
||||
approval_status: appr?.status || "",
|
||||
approval_request_id: appr?.request_id || null,
|
||||
_master: master || {},
|
||||
};
|
||||
});
|
||||
@@ -381,6 +425,13 @@ export default function SalesOrderPage() {
|
||||
|
||||
useEffect(() => { fetchOrders(); }, [fetchOrders]);
|
||||
|
||||
// 결재 처리 완료 시 목록 새로고침
|
||||
useEffect(() => {
|
||||
const handler = () => fetchOrders();
|
||||
window.addEventListener("approval-processed", handler);
|
||||
return () => window.removeEventListener("approval-processed", handler);
|
||||
}, [fetchOrders]);
|
||||
|
||||
// 카테고리 코드→라벨 변환
|
||||
const resolveLabel = useCallback((key: string, code: string) => {
|
||||
if (!code) return "";
|
||||
@@ -705,19 +756,16 @@ export default function SalesOrderPage() {
|
||||
const filters: any[] = [];
|
||||
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
|
||||
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
|
||||
// 관리품목 필터: 다중값(콤마 구분) 저장된 경우도 매칭되도록 contains 사용
|
||||
if (itemSearchDivision !== "all") {
|
||||
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
|
||||
// 코드 또는 라벨이 저장된 경우 모두 조회하기 위해 in 연산자 사용
|
||||
const divValues = [itemSearchDivision];
|
||||
if (divLabel) divValues.push(divLabel);
|
||||
filters.push({ columnName: "division", operator: "in", value: divValues });
|
||||
filters.push({ columnName: "division", operator: "contains", value: itemSearchDivision });
|
||||
}
|
||||
|
||||
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
|
||||
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
|
||||
// 거래처우선 단가방식일 때 거래처 매핑 id 정규화 → 서버 필터 적용
|
||||
// price_mode의 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음)
|
||||
const priceModeLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || "";
|
||||
const isCustomerPrice = priceModeLabel.includes("거래처");
|
||||
const partnerId = masterForm.partner_id;
|
||||
let customerItemIds: Set<string> | null = null;
|
||||
|
||||
if (isCustomerPrice && partnerId) {
|
||||
try {
|
||||
@@ -727,7 +775,36 @@ export default function SalesOrderPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
|
||||
customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean));
|
||||
const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[];
|
||||
if (rawIds.length === 0) {
|
||||
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
|
||||
setItemSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
// UUID와 문자열(item_number) 분리
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const uuidIds = rawIds.filter(v => uuidRegex.test(v));
|
||||
const codeIds = rawIds.filter(v => !uuidRegex.test(v));
|
||||
|
||||
// 문자열(item_number)을 item_info에서 id로 변환
|
||||
let convertedIds: string[] = [];
|
||||
if (codeIds.length > 0) {
|
||||
const convRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: codeIds.length + 10,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || [];
|
||||
convertedIds = convRows.map((r: any) => r.id).filter(Boolean);
|
||||
}
|
||||
|
||||
const finalIds = [...new Set([...uuidIds, ...convertedIds])];
|
||||
if (finalIds.length === 0) {
|
||||
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
|
||||
setItemSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
filters.push({ columnName: "id", operator: "in", value: finalIds });
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
@@ -737,14 +814,9 @@ export default function SalesOrderPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
let rows = resData?.data || resData?.rows || [];
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const serverTotal = resData?.total || resData?.totalCount || rows.length;
|
||||
|
||||
// 거래처우선일 때 연결된 품목만 표시 (클라이언트 필터)
|
||||
if (customerItemIds) {
|
||||
rows = rows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
|
||||
}
|
||||
|
||||
setItemSearchResults(rows);
|
||||
setItemTotal(serverTotal);
|
||||
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
|
||||
@@ -778,8 +850,9 @@ export default function SalesOrderPage() {
|
||||
const selected = Array.from(itemSelectedMap.values());
|
||||
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
|
||||
|
||||
const isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ";
|
||||
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
|
||||
const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || "";
|
||||
const isStandardPrice = pmLabel.includes("기준");
|
||||
const isCustomerPrice = pmLabel.includes("거래처");
|
||||
const partnerId = masterForm.partner_id;
|
||||
|
||||
let customerPriceMap: Record<string, string> = {};
|
||||
@@ -847,10 +920,10 @@ export default function SalesOrderPage() {
|
||||
// 단가 재계산: 단가방식/거래처 변경 시 기존 품목 단가 갱신
|
||||
const recalcPrices = useCallback(async (priceMode: string, partnerId: string) => {
|
||||
if (detailRows.length === 0) return;
|
||||
const STANDARD_CODES = ["CAT_MM0BUZKL_HJ7U", "CAT_MLKG792S_54WJ"];
|
||||
const CUSTOMER_CODES = ["CAT_MM0BV3OS_41DX", "CAT_MLKG7D8K_N8SI"];
|
||||
const isStandard = STANDARD_CODES.includes(priceMode);
|
||||
const isCustomer = CUSTOMER_CODES.includes(priceMode);
|
||||
// price_mode 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음)
|
||||
const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === priceMode)?.label || "";
|
||||
const isStandard = pmLabel.includes("기준");
|
||||
const isCustomer = pmLabel.includes("거래처");
|
||||
|
||||
if (isStandard) {
|
||||
// 품목 기준단가 조회
|
||||
@@ -925,9 +998,11 @@ export default function SalesOrderPage() {
|
||||
setDetailRows((prev) => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
// 조건부 레이어 판단
|
||||
const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W";
|
||||
const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO";
|
||||
// 조건부 레이어 판단 (라벨 기반 — 카테고리 코드는 회사마다 다를 수 있음)
|
||||
const inputModeLabel = (categoryOptions["input_mode"] || []).find((o) => o.code === masterForm.input_mode)?.label || "";
|
||||
const sellModeLabel = (categoryOptions["sell_mode"] || []).find((o) => o.code === masterForm.sell_mode)?.label || "";
|
||||
const isSupplierFirst = inputModeLabel.includes("공급") || inputModeLabel.includes("거래처");
|
||||
const isOverseas = sellModeLabel.includes("해외") || sellModeLabel.includes("수출");
|
||||
|
||||
const handleExcelDownload = async () => {
|
||||
if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
|
||||
@@ -994,6 +1069,42 @@ export default function SalesOrderPage() {
|
||||
>
|
||||
<Trash2 className="w-4 h-4" /> 삭제{checkedIds.length > 0 && ` (${checkedIds.length})`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
className="text-primary border-primary/20 bg-primary/5 hover:bg-primary/10"
|
||||
disabled={checkedIds.length !== 1}
|
||||
onClick={() => {
|
||||
const item = orders.find((o) => o.id === checkedIds[0]);
|
||||
if (!item) return;
|
||||
// 이미 활성 결재가 있으면 차단 (재상신은 rejected/cancelled만 허용)
|
||||
const blockedStatuses = ["requested", "in_progress", "approved", "post_pending"];
|
||||
if (item.approval_status && blockedStatuses.includes(item.approval_status)) {
|
||||
const labelMap: Record<string, string> = {
|
||||
requested: "요청됨", in_progress: "결재중", approved: "승인완료", post_pending: "후결대기",
|
||||
};
|
||||
toast.error(`이미 ${labelMap[item.approval_status]} 상태의 결재가 존재합니다.`);
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("open-approval-modal", {
|
||||
detail: {
|
||||
targetTable: "sales_order_mng",
|
||||
targetRecordId: String(item.order_no),
|
||||
targetRecordData: {
|
||||
order_no: item.order_no,
|
||||
partner_id: item._master?.partner_id || item.partner_id,
|
||||
order_date: item.order_date,
|
||||
item_name: item.part_name,
|
||||
qty: item.qty,
|
||||
amount: item.amount,
|
||||
},
|
||||
defaultTitle: `수주결재: ${item.order_no} - ${item.part_name || ""}`,
|
||||
defaultDescription: `수주번호: ${item.order_no}\n품목: ${item.part_name || ""}\n수량: ${item.qty || 0}\n금액: ${Number(item.amount || 0).toLocaleString()}원`,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<ClipboardList className="w-4 h-4" /> 결재 상신
|
||||
</Button>
|
||||
<div className="h-5 w-px bg-border mx-0.5" />
|
||||
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-4 h-4" /> 엑셀 업로드
|
||||
@@ -1030,6 +1141,7 @@ export default function SalesOrderPage() {
|
||||
<col style={{ width: "90px" }} />
|
||||
<col style={{ width: "110px" }} />
|
||||
<col style={{ width: "100px" }} />
|
||||
<col style={{ width: "90px" }} />
|
||||
<col style={{ width: "120px" }} />
|
||||
</colgroup>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
@@ -1095,12 +1207,22 @@ export default function SalesOrderPage() {
|
||||
</TableRow>
|
||||
) : (
|
||||
ts.groupData(paginatedRows).map((row: any) => {
|
||||
// 그룹 헤더 행 렌더링
|
||||
if (row._isGroupHeader) {
|
||||
return (
|
||||
<TableRow key={`header-${row._groupValue}-${Math.random()}`} className="bg-primary/5 font-semibold border-t-2 border-primary/30">
|
||||
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
|
||||
📂 {row._groupValue} ({row._groupCount}건)
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
// 그룹 요약 행 렌더링
|
||||
if (row._isGroupSummary) {
|
||||
return (
|
||||
<TableRow key={`summary-${row._groupKey || Math.random()}`} className="bg-muted/60 font-semibold border-t border-primary/20">
|
||||
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
|
||||
{row._groupLabel || "합계"}: {row._count ? `${row._count}건` : ""}
|
||||
{row._groupValue || "합계"}
|
||||
{row.qty ? ` · 수량 ${Number(row.qty).toLocaleString()}` : ""}
|
||||
{row.amount ? ` · 금액 ${Number(row.amount).toLocaleString()}` : ""}
|
||||
</TableCell>
|
||||
@@ -1146,6 +1268,24 @@ export default function SalesOrderPage() {
|
||||
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
|
||||
<TableCell className="text-[13px]">{row.due_date || ""}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{row.approval_status && row.approval_request_id ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.dispatchEvent(new CustomEvent("open-approval-detail-modal", {
|
||||
detail: { requestId: row.approval_request_id },
|
||||
}));
|
||||
}}
|
||||
className={cn("inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold cursor-pointer hover:opacity-80 transition-opacity", APPROVAL_STATUS_CLASS[row.approval_status] || "bg-muted text-muted-foreground")}
|
||||
title="결재 상세보기"
|
||||
>
|
||||
{APPROVAL_STATUS_LABEL[row.approval_status] || row.approval_status}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40 text-[11px]">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.memo || ""}</span></TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -1475,6 +1615,9 @@ export default function SalesOrderPage() {
|
||||
</div>
|
||||
<Button size="sm" onClick={() => {
|
||||
setItemSelectedMap(new Map());
|
||||
setItemSearchResults([]);
|
||||
setItemTotal(0);
|
||||
setItemTotalPages(1);
|
||||
setItemPage(1);
|
||||
setItemPageInput("1");
|
||||
setItemSearchKeyword("");
|
||||
@@ -1680,7 +1823,16 @@ export default function SalesOrderPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{itemSearchResults.length === 0 ? (
|
||||
{itemSearchLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-12 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
<span className="text-xs text-muted-foreground">품목을 불러오는 중...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : itemSearchResults.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">검색 결과가 없어요</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -1986,7 +1986,7 @@ export default function BomManagementPage() {
|
||||
|
||||
{/* ─── BOM 등록/수정 모달 ─────────────────── */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditMode ? "BOM 수정" : "BOM 등록"}</DialogTitle>
|
||||
<DialogDescription>{isEditMode ? "BOM 정보를 수정해요" : "새로운 BOM을 등록해요"}</DialogDescription>
|
||||
|
||||
@@ -606,7 +606,7 @@ export default function InspectionManagementPage() {
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-col gap-3 p-3 h-[calc(100vh-4rem)] overflow-auto">
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<div className="bg-card rounded-lg border">
|
||||
|
||||
@@ -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";
|
||||
@@ -34,12 +36,6 @@ const GRID_COLUMNS = [
|
||||
{ key: "is_active", label: "사용여부" },
|
||||
];
|
||||
|
||||
const INSPECTION_TYPES = [
|
||||
{ key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] },
|
||||
{ key: "outgoing_inspection", label: "출하검사", matchLabels: ["출하검사", "출고검사", "출하", "출고"] },
|
||||
{ key: "process_inspection", label: "공정검사", matchLabels: ["공정검사", "공정"] },
|
||||
{ key: "final_inspection", label: "최종검사", matchLabels: ["최종검사", "최종", "완제품검사"] },
|
||||
] as const;
|
||||
|
||||
type InspectionRow = {
|
||||
id: string;
|
||||
@@ -79,6 +75,15 @@ export default function ItemInspectionInfoPage() {
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
|
||||
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
// 검사유형 목록 (검사기준 카테고리 기반)
|
||||
const INSPECTION_TYPES = useMemo(() => {
|
||||
return inspTypeCatOptions.map((cat) => ({
|
||||
key: cat.code,
|
||||
label: cat.label,
|
||||
matchLabels: [cat.code, cat.label],
|
||||
}));
|
||||
}, [inspTypeCatOptions]);
|
||||
const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [judgmentCatOptions, setJudgmentCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspUnitCatOptions, setInspUnitCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
@@ -96,6 +101,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 +478,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 +729,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 +887,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);
|
||||
@@ -752,11 +995,11 @@ export default function ItemInspectionInfoPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{editMode ? "품목검사정보 수정" : "품목검사정보 등록"}</DialogTitle>
|
||||
<DialogDescription className="sr-only">품목검사정보를 등록합니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
|
||||
{/* 품목 정보 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">품목 정보</h4>
|
||||
@@ -918,7 +1161,7 @@ export default function ItemInspectionInfoPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}저장
|
||||
@@ -930,6 +1173,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ const FLAT_COLUMNS = [
|
||||
{ key: "unit_price", label: "단가", source: "detail" },
|
||||
{ key: "amount", label: "금액", source: "detail" },
|
||||
{ key: "due_date", label: "납기일", source: "detail" },
|
||||
{ key: "approval_status", label: "결재상태", source: "master" },
|
||||
{ key: "memo", label: "메모", source: "master" },
|
||||
];
|
||||
|
||||
@@ -66,8 +67,26 @@ const DETAIL_HEADER_COLS = FLAT_COLUMNS.filter((c) => c.source === "detail");
|
||||
// 필터용 전체 키
|
||||
const GRID_COLUMNS_CONFIG = FLAT_COLUMNS.map(({ key, label }) => ({ key, label }));
|
||||
|
||||
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(14) = 15
|
||||
const TOTAL_COLS = 15;
|
||||
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(15) = 16
|
||||
const TOTAL_COLS = 16;
|
||||
|
||||
// 결재상태 라벨/색상
|
||||
const APPROVAL_STATUS_LABEL: Record<string, string> = {
|
||||
requested: "요청",
|
||||
in_progress: "결재중",
|
||||
approved: "승인완료",
|
||||
rejected: "반려",
|
||||
cancelled: "회수",
|
||||
post_pending: "후결대기",
|
||||
};
|
||||
const APPROVAL_STATUS_CLASS: Record<string, string> = {
|
||||
requested: "bg-secondary text-secondary-foreground",
|
||||
in_progress: "bg-primary/10 text-primary border border-primary/20",
|
||||
approved: "bg-emerald-500/10 text-emerald-600 border border-emerald-500/20",
|
||||
rejected: "bg-destructive/10 text-destructive border border-destructive/20",
|
||||
cancelled: "bg-muted text-muted-foreground",
|
||||
post_pending: "bg-warning/10 text-warning",
|
||||
};
|
||||
|
||||
// 헤더 필터 Popover
|
||||
function HeaderFilterPopover({
|
||||
@@ -333,6 +352,28 @@ export default function SalesOrderPage() {
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
// 결재 상태 조인 (target_table='sales_order_mng', target_record_id = order_no)
|
||||
let approvalMap: Record<string, any> = {};
|
||||
if (orderNos.length > 0) {
|
||||
try {
|
||||
const apprRes = await apiClient.post(`/table-management/tables/approval_requests/data`, {
|
||||
page: 1, size: orderNos.length + 10,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "target_table", operator: "equals", value: "sales_order_mng" },
|
||||
{ columnName: "target_record_id", operator: "in", value: orderNos.map(String) },
|
||||
] },
|
||||
autoFilter: true,
|
||||
sort: { columnName: "request_id", order: "desc" },
|
||||
});
|
||||
const apprs = apprRes.data?.data?.data || apprRes.data?.data?.rows || [];
|
||||
// 같은 order_no에 여러 결재가 있으면 최신만 (sort desc 첫 번째)
|
||||
for (const a of apprs) {
|
||||
const rid = String(a.target_record_id);
|
||||
if (!approvalMap[rid]) approvalMap[rid] = a;
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
// part_code → item_info 조인
|
||||
const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))];
|
||||
let itemMap: Record<string, any> = {};
|
||||
@@ -359,6 +400,7 @@ export default function SalesOrderPage() {
|
||||
const item = itemMap[row.part_code];
|
||||
const master = masterMap[row.order_no];
|
||||
const rawUnit = row.unit || item?.inventory_unit || "";
|
||||
const appr = approvalMap[String(row.order_no)] || null;
|
||||
return {
|
||||
...row,
|
||||
part_name: row.part_name || item?.item_name || "",
|
||||
@@ -366,6 +408,8 @@ export default function SalesOrderPage() {
|
||||
material: row.material || (item ? (resolveLabel("item_material", item.material) || item.material || "") : ""),
|
||||
unit: resolveLabel("item_inventory_unit", rawUnit) || rawUnit,
|
||||
memo: row.memo || master?.memo || "",
|
||||
approval_status: appr?.status || "",
|
||||
approval_request_id: appr?.request_id || null,
|
||||
_master: master || {},
|
||||
};
|
||||
});
|
||||
@@ -381,6 +425,13 @@ export default function SalesOrderPage() {
|
||||
|
||||
useEffect(() => { fetchOrders(); }, [fetchOrders]);
|
||||
|
||||
// 결재 처리 완료 시 목록 새로고침
|
||||
useEffect(() => {
|
||||
const handler = () => fetchOrders();
|
||||
window.addEventListener("approval-processed", handler);
|
||||
return () => window.removeEventListener("approval-processed", handler);
|
||||
}, [fetchOrders]);
|
||||
|
||||
// 카테고리 코드→라벨 변환
|
||||
const resolveLabel = useCallback((key: string, code: string) => {
|
||||
if (!code) return "";
|
||||
@@ -705,19 +756,16 @@ export default function SalesOrderPage() {
|
||||
const filters: any[] = [];
|
||||
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
|
||||
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
|
||||
// 관리품목 필터: 다중값(콤마 구분) 저장된 경우도 매칭되도록 contains 사용
|
||||
if (itemSearchDivision !== "all") {
|
||||
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
|
||||
// 코드 또는 라벨이 저장된 경우 모두 조회하기 위해 in 연산자 사용
|
||||
const divValues = [itemSearchDivision];
|
||||
if (divLabel) divValues.push(divLabel);
|
||||
filters.push({ columnName: "division", operator: "in", value: divValues });
|
||||
filters.push({ columnName: "division", operator: "contains", value: itemSearchDivision });
|
||||
}
|
||||
|
||||
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
|
||||
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
|
||||
// 거래처우선 단가방식일 때 거래처 매핑 id 정규화 → 서버 필터 적용
|
||||
// price_mode의 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음)
|
||||
const priceModeLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || "";
|
||||
const isCustomerPrice = priceModeLabel.includes("거래처");
|
||||
const partnerId = masterForm.partner_id;
|
||||
let customerItemIds: Set<string> | null = null;
|
||||
|
||||
if (isCustomerPrice && partnerId) {
|
||||
try {
|
||||
@@ -727,7 +775,36 @@ export default function SalesOrderPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
|
||||
customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean));
|
||||
const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[];
|
||||
if (rawIds.length === 0) {
|
||||
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
|
||||
setItemSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
// UUID와 문자열(item_number) 분리
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const uuidIds = rawIds.filter(v => uuidRegex.test(v));
|
||||
const codeIds = rawIds.filter(v => !uuidRegex.test(v));
|
||||
|
||||
// 문자열(item_number)을 item_info에서 id로 변환
|
||||
let convertedIds: string[] = [];
|
||||
if (codeIds.length > 0) {
|
||||
const convRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: codeIds.length + 10,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || [];
|
||||
convertedIds = convRows.map((r: any) => r.id).filter(Boolean);
|
||||
}
|
||||
|
||||
const finalIds = [...new Set([...uuidIds, ...convertedIds])];
|
||||
if (finalIds.length === 0) {
|
||||
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
|
||||
setItemSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
filters.push({ columnName: "id", operator: "in", value: finalIds });
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
@@ -737,14 +814,9 @@ export default function SalesOrderPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
let rows = resData?.data || resData?.rows || [];
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const serverTotal = resData?.total || resData?.totalCount || rows.length;
|
||||
|
||||
// 거래처우선일 때 연결된 품목만 표시 (클라이언트 필터)
|
||||
if (customerItemIds) {
|
||||
rows = rows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
|
||||
}
|
||||
|
||||
setItemSearchResults(rows);
|
||||
setItemTotal(serverTotal);
|
||||
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
|
||||
@@ -778,8 +850,9 @@ export default function SalesOrderPage() {
|
||||
const selected = Array.from(itemSelectedMap.values());
|
||||
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
|
||||
|
||||
const isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ";
|
||||
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
|
||||
const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || "";
|
||||
const isStandardPrice = pmLabel.includes("기준");
|
||||
const isCustomerPrice = pmLabel.includes("거래처");
|
||||
const partnerId = masterForm.partner_id;
|
||||
|
||||
let customerPriceMap: Record<string, string> = {};
|
||||
@@ -847,10 +920,10 @@ export default function SalesOrderPage() {
|
||||
// 단가 재계산: 단가방식/거래처 변경 시 기존 품목 단가 갱신
|
||||
const recalcPrices = useCallback(async (priceMode: string, partnerId: string) => {
|
||||
if (detailRows.length === 0) return;
|
||||
const STANDARD_CODES = ["CAT_MM0BUZKL_HJ7U", "CAT_MLKG792S_54WJ"];
|
||||
const CUSTOMER_CODES = ["CAT_MM0BV3OS_41DX", "CAT_MLKG7D8K_N8SI"];
|
||||
const isStandard = STANDARD_CODES.includes(priceMode);
|
||||
const isCustomer = CUSTOMER_CODES.includes(priceMode);
|
||||
// price_mode 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음)
|
||||
const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === priceMode)?.label || "";
|
||||
const isStandard = pmLabel.includes("기준");
|
||||
const isCustomer = pmLabel.includes("거래처");
|
||||
|
||||
if (isStandard) {
|
||||
// 품목 기준단가 조회
|
||||
@@ -925,9 +998,11 @@ export default function SalesOrderPage() {
|
||||
setDetailRows((prev) => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
// 조건부 레이어 판단
|
||||
const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W";
|
||||
const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO";
|
||||
// 조건부 레이어 판단 (라벨 기반 — 카테고리 코드는 회사마다 다를 수 있음)
|
||||
const inputModeLabel = (categoryOptions["input_mode"] || []).find((o) => o.code === masterForm.input_mode)?.label || "";
|
||||
const sellModeLabel = (categoryOptions["sell_mode"] || []).find((o) => o.code === masterForm.sell_mode)?.label || "";
|
||||
const isSupplierFirst = inputModeLabel.includes("공급") || inputModeLabel.includes("거래처");
|
||||
const isOverseas = sellModeLabel.includes("해외") || sellModeLabel.includes("수출");
|
||||
|
||||
const handleExcelDownload = async () => {
|
||||
if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
|
||||
@@ -994,6 +1069,42 @@ export default function SalesOrderPage() {
|
||||
>
|
||||
<Trash2 className="w-4 h-4" /> 삭제{checkedIds.length > 0 && ` (${checkedIds.length})`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
className="text-primary border-primary/20 bg-primary/5 hover:bg-primary/10"
|
||||
disabled={checkedIds.length !== 1}
|
||||
onClick={() => {
|
||||
const item = orders.find((o) => o.id === checkedIds[0]);
|
||||
if (!item) return;
|
||||
// 이미 활성 결재가 있으면 차단 (재상신은 rejected/cancelled만 허용)
|
||||
const blockedStatuses = ["requested", "in_progress", "approved", "post_pending"];
|
||||
if (item.approval_status && blockedStatuses.includes(item.approval_status)) {
|
||||
const labelMap: Record<string, string> = {
|
||||
requested: "요청됨", in_progress: "결재중", approved: "승인완료", post_pending: "후결대기",
|
||||
};
|
||||
toast.error(`이미 ${labelMap[item.approval_status]} 상태의 결재가 존재합니다.`);
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("open-approval-modal", {
|
||||
detail: {
|
||||
targetTable: "sales_order_mng",
|
||||
targetRecordId: String(item.order_no),
|
||||
targetRecordData: {
|
||||
order_no: item.order_no,
|
||||
partner_id: item._master?.partner_id || item.partner_id,
|
||||
order_date: item.order_date,
|
||||
item_name: item.part_name,
|
||||
qty: item.qty,
|
||||
amount: item.amount,
|
||||
},
|
||||
defaultTitle: `수주결재: ${item.order_no} - ${item.part_name || ""}`,
|
||||
defaultDescription: `수주번호: ${item.order_no}\n품목: ${item.part_name || ""}\n수량: ${item.qty || 0}\n금액: ${Number(item.amount || 0).toLocaleString()}원`,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<ClipboardList className="w-4 h-4" /> 결재 상신
|
||||
</Button>
|
||||
<div className="h-5 w-px bg-border mx-0.5" />
|
||||
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-4 h-4" /> 엑셀 업로드
|
||||
@@ -1030,6 +1141,7 @@ export default function SalesOrderPage() {
|
||||
<col style={{ width: "90px" }} />
|
||||
<col style={{ width: "110px" }} />
|
||||
<col style={{ width: "100px" }} />
|
||||
<col style={{ width: "90px" }} />
|
||||
<col style={{ width: "120px" }} />
|
||||
</colgroup>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
@@ -1095,12 +1207,22 @@ export default function SalesOrderPage() {
|
||||
</TableRow>
|
||||
) : (
|
||||
ts.groupData(paginatedRows).map((row: any) => {
|
||||
// 그룹 헤더 행 렌더링
|
||||
if (row._isGroupHeader) {
|
||||
return (
|
||||
<TableRow key={`header-${row._groupValue}-${Math.random()}`} className="bg-primary/5 font-semibold border-t-2 border-primary/30">
|
||||
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
|
||||
📂 {row._groupValue} ({row._groupCount}건)
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
// 그룹 요약 행 렌더링
|
||||
if (row._isGroupSummary) {
|
||||
return (
|
||||
<TableRow key={`summary-${row._groupKey || Math.random()}`} className="bg-muted/60 font-semibold border-t border-primary/20">
|
||||
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
|
||||
{row._groupLabel || "합계"}: {row._count ? `${row._count}건` : ""}
|
||||
{row._groupValue || "합계"}
|
||||
{row.qty ? ` · 수량 ${Number(row.qty).toLocaleString()}` : ""}
|
||||
{row.amount ? ` · 금액 ${Number(row.amount).toLocaleString()}` : ""}
|
||||
</TableCell>
|
||||
@@ -1146,6 +1268,24 @@ export default function SalesOrderPage() {
|
||||
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
|
||||
<TableCell className="text-[13px]">{row.due_date || ""}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{row.approval_status && row.approval_request_id ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.dispatchEvent(new CustomEvent("open-approval-detail-modal", {
|
||||
detail: { requestId: row.approval_request_id },
|
||||
}));
|
||||
}}
|
||||
className={cn("inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold cursor-pointer hover:opacity-80 transition-opacity", APPROVAL_STATUS_CLASS[row.approval_status] || "bg-muted text-muted-foreground")}
|
||||
title="결재 상세보기"
|
||||
>
|
||||
{APPROVAL_STATUS_LABEL[row.approval_status] || row.approval_status}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40 text-[11px]">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.memo || ""}</span></TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -1475,6 +1615,9 @@ export default function SalesOrderPage() {
|
||||
</div>
|
||||
<Button size="sm" onClick={() => {
|
||||
setItemSelectedMap(new Map());
|
||||
setItemSearchResults([]);
|
||||
setItemTotal(0);
|
||||
setItemTotalPages(1);
|
||||
setItemPage(1);
|
||||
setItemPageInput("1");
|
||||
setItemSearchKeyword("");
|
||||
@@ -1680,7 +1823,16 @@ export default function SalesOrderPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{itemSearchResults.length === 0 ? (
|
||||
{itemSearchLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-12 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
<span className="text-xs text-muted-foreground">품목을 불러오는 중...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : itemSearchResults.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">검색 결과가 없어요</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -104,10 +104,10 @@ export default function InboundOutboundPage() {
|
||||
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
|
||||
if (itemCodes.length > 0) {
|
||||
try {
|
||||
// 단위 카테고리 코드→라벨 매핑 로드
|
||||
// 재고단위 카테고리 코드→라벨 매핑 로드
|
||||
let unitLabelMap: Record<string, string> = {};
|
||||
try {
|
||||
const catRes = await apiClient.get("/table-categories/item_info/unit/values");
|
||||
const catRes = await apiClient.get("/table-categories/item_info/inventory_unit/values");
|
||||
if (catRes.data?.success && catRes.data.data?.length > 0) {
|
||||
const flatten = (vals: any[]) => {
|
||||
for (const v of vals) {
|
||||
@@ -127,7 +127,7 @@ export default function InboundOutboundPage() {
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
const map: Record<string, { item_name: string; unit: string }> = {};
|
||||
for (const i of items) {
|
||||
const rawUnit = i.unit || "";
|
||||
const rawUnit = i.inventory_unit || "";
|
||||
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit };
|
||||
}
|
||||
setItemMap(map);
|
||||
|
||||
@@ -106,6 +106,9 @@ const GRID_COLUMNS = [
|
||||
{ key: "customer_name", label: "거래처" },
|
||||
{ key: "item_number", label: "품목코드" },
|
||||
{ key: "item_name", label: "품목명" },
|
||||
{ key: "width", label: "가로" },
|
||||
{ key: "height", label: "세로" },
|
||||
{ key: "thickness", label: "두께" },
|
||||
{ key: "spec", label: "규격" },
|
||||
{ key: "outbound_qty", label: "출고수량" },
|
||||
{ key: "unit_price", label: "단가" },
|
||||
@@ -115,8 +118,8 @@ const GRID_COLUMNS = [
|
||||
{ key: "remark", label: "비고" },
|
||||
];
|
||||
|
||||
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(15) = 16
|
||||
const TOTAL_COLS = 16;
|
||||
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(18) = 19
|
||||
const TOTAL_COLS = 19;
|
||||
|
||||
// 헤더 필터 Popover
|
||||
function HeaderFilterPopover({
|
||||
@@ -626,6 +629,9 @@ export default function OutboundPage() {
|
||||
item_number: si.item_code,
|
||||
item_name: si.item_name,
|
||||
spec: si.spec || "",
|
||||
width: (si as any).width || "",
|
||||
height: (si as any).height || "",
|
||||
thickness: (si as any).thickness || "",
|
||||
material: si.material || "",
|
||||
unit: "EA",
|
||||
outbound_qty: si.remain_qty,
|
||||
@@ -652,6 +658,9 @@ export default function OutboundPage() {
|
||||
item_number: po.item_code,
|
||||
item_name: po.item_name,
|
||||
spec: po.spec || "",
|
||||
width: (po as any).width || "",
|
||||
height: (po as any).height || "",
|
||||
thickness: (po as any).thickness || "",
|
||||
material: po.material || "",
|
||||
unit: "EA",
|
||||
outbound_qty: po.received_qty,
|
||||
@@ -678,6 +687,9 @@ export default function OutboundPage() {
|
||||
item_number: item.item_number,
|
||||
item_name: item.item_name,
|
||||
spec: item.spec || "",
|
||||
width: (item as any).width || "",
|
||||
height: (item as any).height || "",
|
||||
thickness: (item as any).thickness || "",
|
||||
material: item.material || "",
|
||||
unit: item.inventory_unit || "EA",
|
||||
outbound_qty: 0,
|
||||
@@ -896,6 +908,9 @@ export default function OutboundPage() {
|
||||
<col style={{ width: "110px" }} />
|
||||
<col style={{ width: "100px" }} />
|
||||
<col style={{ width: "140px" }} />
|
||||
<col style={{ width: "70px" }} />
|
||||
<col style={{ width: "70px" }} />
|
||||
<col style={{ width: "70px" }} />
|
||||
<col style={{ width: "90px" }} />
|
||||
<col style={{ width: "80px" }} />
|
||||
<col style={{ width: "80px" }} />
|
||||
@@ -1007,6 +1022,9 @@ export default function OutboundPage() {
|
||||
<TableCell className="text-[13px] truncate max-w-[110px]"><span className="block truncate">{row.customer_name || ""}</span></TableCell>
|
||||
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
|
||||
<TableCell className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-muted-foreground">{(row as any).width || "-"}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-muted-foreground">{(row as any).height || "-"}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-muted-foreground">{(row as any).thickness || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
|
||||
@@ -1372,7 +1390,7 @@ export default function OutboundPage() {
|
||||
{resolveCat("outbound_type", item.outbound_type) || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px] p-2">
|
||||
<TableCell className="max-w-[220px] p-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="truncate font-medium" title={item.item_name}>
|
||||
{item.item_name}
|
||||
@@ -1381,6 +1399,13 @@ export default function OutboundPage() {
|
||||
{item.item_number}
|
||||
{item.spec ? ` | ${item.spec}` : ""}
|
||||
</span>
|
||||
{((item as any).width || (item as any).height || (item as any).thickness) && (
|
||||
<span className="text-muted-foreground truncate text-[10px]">
|
||||
{(item as any).width && `W ${(item as any).width}`}
|
||||
{(item as any).height && ` × H ${(item as any).height}`}
|
||||
{(item as any).thickness && ` × T ${(item as any).thickness}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="p-2 text-[11px]">{item.reference_number}</TableCell>
|
||||
@@ -1535,13 +1560,20 @@ function SourceShipmentInstructionTable({
|
||||
? new Date(si.instruction_date).toLocaleDateString("ko-KR")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] p-2">
|
||||
<TableCell className="max-w-[220px] p-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="truncate font-medium" title={si.item_name}>{si.item_name}</span>
|
||||
<span className="text-muted-foreground truncate text-[10px]" title={`${si.item_code}${si.spec ? ` | ${si.spec}` : ""}`}>
|
||||
{si.item_code}
|
||||
{si.spec ? ` | ${si.spec}` : ""}
|
||||
</span>
|
||||
{((si as any).width || (si as any).height || (si as any).thickness) && (
|
||||
<span className="text-muted-foreground truncate text-[10px]">
|
||||
{(si as any).width && `W ${(si as any).width}`}
|
||||
{(si as any).height && ` × H ${(si as any).height}`}
|
||||
{(si as any).thickness && ` × T ${(si as any).thickness}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="p-2 text-right">
|
||||
@@ -1612,13 +1644,20 @@ function SourcePurchaseOrderTable({
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[120px] truncate p-2 font-medium" title={po.purchase_no}>{po.purchase_no}</TableCell>
|
||||
<TableCell className="max-w-[120px] truncate p-2" title={po.supplier_name}>{po.supplier_name}</TableCell>
|
||||
<TableCell className="max-w-[200px] p-2">
|
||||
<TableCell className="max-w-[220px] p-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="truncate font-medium" title={po.item_name}>{po.item_name}</span>
|
||||
<span className="text-muted-foreground truncate text-[10px]" title={`${po.item_code}${po.spec ? ` | ${po.spec}` : ""}`}>
|
||||
{po.item_code}
|
||||
{po.spec ? ` | ${po.spec}` : ""}
|
||||
</span>
|
||||
{((po as any).width || (po as any).height || (po as any).thickness) && (
|
||||
<span className="text-muted-foreground truncate text-[10px]">
|
||||
{(po as any).width && `W ${(po as any).width}`}
|
||||
{(po as any).height && ` × H ${(po as any).height}`}
|
||||
{(po as any).thickness && ` × T ${(po as any).thickness}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="p-2 text-right">
|
||||
@@ -1692,6 +1731,13 @@ function SourceItemTable({
|
||||
<span className="text-muted-foreground truncate text-[10px]" title={item.item_number}>
|
||||
{item.item_number}
|
||||
</span>
|
||||
{((item as any).width || (item as any).height || (item as any).thickness) && (
|
||||
<span className="text-muted-foreground truncate text-[10px]">
|
||||
{(item as any).width && `W ${(item as any).width}`}
|
||||
{(item as any).height && ` × H ${(item as any).height}`}
|
||||
{(item as any).thickness && ` × T ${(item as any).thickness}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[100px] truncate p-2" title={item.spec || "-"}>{item.spec || "-"}</TableCell>
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
@@ -388,7 +389,7 @@ export default function ReceivingPage() {
|
||||
const flatRows = useMemo(() => {
|
||||
return data.map((row) => ({
|
||||
...row,
|
||||
inbound_type: resolveInboundType(row.inbound_type),
|
||||
inbound_type: resolveInboundType((row as any).detail_inbound_type || row.inbound_type),
|
||||
source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "",
|
||||
}));
|
||||
}, [data]);
|
||||
@@ -595,7 +596,7 @@ export default function ReceivingPage() {
|
||||
setSelectedItems(
|
||||
grouped.map((g) => ({
|
||||
key: g.id,
|
||||
inbound_type: g.inbound_type || "",
|
||||
inbound_type: (g as any).detail_inbound_type || g.inbound_type || "",
|
||||
reference_number: g.reference_number || "",
|
||||
supplier_code: (g as any).supplier_code || "",
|
||||
supplier_name: g.supplier_name || "",
|
||||
@@ -635,7 +636,7 @@ export default function ReceivingPage() {
|
||||
setPurchaseOrders([]);
|
||||
setShipments([]);
|
||||
setItems([]);
|
||||
setSelectedItems([]);
|
||||
// 선택 품목은 유지 (여러 유형 혼합 가능)
|
||||
setSourcePage(1);
|
||||
setSourceTotalCount(0);
|
||||
loadSourceData(type, undefined, 1);
|
||||
@@ -651,7 +652,7 @@ export default function ReceivingPage() {
|
||||
...prev,
|
||||
{
|
||||
key,
|
||||
inbound_type: "구매입고",
|
||||
inbound_type: modalInboundType,
|
||||
reference_number: po.purchase_no,
|
||||
supplier_code: po.supplier_code,
|
||||
supplier_name: po.supplier_name,
|
||||
@@ -677,7 +678,7 @@ export default function ReceivingPage() {
|
||||
...prev,
|
||||
{
|
||||
key,
|
||||
inbound_type: "반품입고",
|
||||
inbound_type: modalInboundType,
|
||||
reference_number: sh.instruction_no,
|
||||
supplier_code: "",
|
||||
supplier_name: sh.partner_id,
|
||||
@@ -695,15 +696,15 @@ export default function ReceivingPage() {
|
||||
]);
|
||||
};
|
||||
|
||||
// 품목 추가
|
||||
// 품목 추가 (현재 선택된 입고유형 사용)
|
||||
const addItem = (item: ItemSource) => {
|
||||
const key = `item-${item.id}`;
|
||||
const key = `item-${item.id}-${modalInboundType}`;
|
||||
if (selectedItems.some((s) => s.key === key)) return;
|
||||
setSelectedItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key,
|
||||
inbound_type: "기타입고",
|
||||
inbound_type: modalInboundType,
|
||||
reference_number: item.item_number,
|
||||
supplier_code: "",
|
||||
supplier_name: "",
|
||||
@@ -1009,11 +1010,11 @@ export default function ReceivingPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedRows.map((row) => {
|
||||
paginatedRows.map((row, idx) => {
|
||||
const isChecked = checkedIds.includes(row.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
key={`${row.id}-${idx}`}
|
||||
className={cn(
|
||||
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
|
||||
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
|
||||
@@ -1401,6 +1402,7 @@ export default function ReceivingPage() {
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[30px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
|
||||
<TableHead className="w-[70px] p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고유형</TableHead>
|
||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">참조번호</TableHead>
|
||||
<TableHead className="w-[80px] p-2 text-right">
|
||||
@@ -1421,6 +1423,9 @@ export default function ReceivingPage() {
|
||||
<TableCell className="p-2 text-center">
|
||||
{idx + 1}
|
||||
</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Badge variant="outline" className="text-[10px]">{item.inbound_type || modalInboundType}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px] p-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="truncate font-medium" title={item.item_name}>
|
||||
|
||||
@@ -142,6 +142,9 @@ const GRID_COLUMNS = [
|
||||
{ key: "image", label: "이미지", type: "image" },
|
||||
{ key: "division", label: "관리품목" },
|
||||
{ key: "type", label: "품목구분" },
|
||||
{ key: "width", label: "가로", align: "right" as const },
|
||||
{ key: "height", label: "세로", align: "right" as const },
|
||||
{ key: "thickness", label: "두께", align: "right" as const },
|
||||
{ key: "size", label: "규격" },
|
||||
{ key: "unit", label: "단위" },
|
||||
{ key: "material", label: "재질" },
|
||||
@@ -160,6 +163,9 @@ const FORM_FIELDS = [
|
||||
{ key: "item_name", label: "품명", type: "text", required: true },
|
||||
{ key: "division", label: "관리품목", type: "multi-category" },
|
||||
{ key: "type", label: "품목구분", type: "category" },
|
||||
{ key: "width", label: "가로", type: "text", placeholder: "숫자 입력 (예: 1000)" },
|
||||
{ key: "height", label: "세로", type: "text", placeholder: "숫자 입력 (예: 2000)" },
|
||||
{ key: "thickness", label: "두께", type: "text", placeholder: "숫자 입력 (예: 10)" },
|
||||
{ key: "size", label: "규격", type: "text" },
|
||||
{ key: "unit", label: "단위", type: "category" },
|
||||
{ key: "material", label: "재질", type: "category" },
|
||||
@@ -383,8 +389,12 @@ export default function ItemInfoPage() {
|
||||
}
|
||||
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
setRawItems(raw);
|
||||
const data = raw.map((r: any) => {
|
||||
// item_number 내림차순 정렬 (최근 품목이 위로, 자연 정렬)
|
||||
const sortedRaw = [...raw].sort((a: any, b: any) =>
|
||||
String(b.item_number || "").localeCompare(String(a.item_number || ""), undefined, { numeric: true, sensitivity: "base" })
|
||||
);
|
||||
setRawItems(sortedRaw);
|
||||
const data = sortedRaw.map((r: any) => {
|
||||
const converted = { ...r };
|
||||
for (const col of CATEGORY_COLUMNS) {
|
||||
if (converted[col]) converted[col] = resolve(col, converted[col]);
|
||||
|
||||
@@ -1986,7 +1986,7 @@ export default function BomManagementPage() {
|
||||
|
||||
{/* ─── BOM 등록/수정 모달 ─────────────────── */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditMode ? "BOM 수정" : "BOM 등록"}</DialogTitle>
|
||||
<DialogDescription>{isEditMode ? "BOM 정보를 수정해요" : "새로운 BOM을 등록해요"}</DialogDescription>
|
||||
|
||||
@@ -76,6 +76,9 @@ const GRID_COLUMNS_CONFIG = [
|
||||
{ key: "supplier_name", label: "공급업체" },
|
||||
{ key: "item_code", label: "품번" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "width", label: "가로" },
|
||||
{ key: "height", label: "세로" },
|
||||
{ key: "thickness", label: "두께" },
|
||||
{ key: "spec", label: "규격" },
|
||||
{ key: "order_qty", label: "발주수량" },
|
||||
{ key: "received_qty", label: "입고수량" },
|
||||
@@ -91,6 +94,9 @@ const MODAL_DETAIL_COLUMNS = [
|
||||
{ key: "item_code", label: "품번", width: "min-w-[120px]" },
|
||||
{ key: "item_name", label: "품명", width: "min-w-[150px]" },
|
||||
{ key: "supplier", label: "공급업체", width: "min-w-[150px]" },
|
||||
{ key: "width", label: "가로", width: "min-w-[70px]" },
|
||||
{ key: "height", label: "세로", width: "min-w-[70px]" },
|
||||
{ key: "thickness", label: "두께", width: "min-w-[70px]" },
|
||||
{ key: "spec", label: "규격", width: "min-w-[80px]" },
|
||||
{ key: "unit", label: "단위", width: "min-w-[90px]" },
|
||||
{ key: "order_qty", label: "발주수량", width: "min-w-[90px]" },
|
||||
@@ -351,6 +357,9 @@ export default function PurchaseOrderPage() {
|
||||
...row,
|
||||
item_name: row.item_name || item?.item_name || "",
|
||||
spec: row.spec || item?.size || "",
|
||||
width: row.width || item?.width || "",
|
||||
height: row.height || item?.height || "",
|
||||
thickness: row.thickness || item?.thickness || "",
|
||||
unit: resolveLabel("item_inventory_unit", rawUnit) || rawUnit,
|
||||
status: master?.status || "",
|
||||
supplier_name: master?.supplier_name || "",
|
||||
@@ -641,6 +650,9 @@ export default function PurchaseOrderPage() {
|
||||
item_code: itemCode,
|
||||
item_name: item.item_name,
|
||||
spec: item.size || "",
|
||||
width: item.width || "",
|
||||
height: item.height || "",
|
||||
thickness: item.thickness || "",
|
||||
material: getCategoryLabel("item_material", item.material) || item.material || "",
|
||||
unit: getCategoryLabel("item_inventory_unit", item.inventory_unit) || item.inventory_unit || "",
|
||||
order_qty: "",
|
||||
@@ -1087,6 +1099,12 @@ export default function PurchaseOrderPage() {
|
||||
);
|
||||
case "spec":
|
||||
return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec}</TableCell>;
|
||||
case "width":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono text-muted-foreground">{row.width || "-"}</TableCell>;
|
||||
case "height":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono text-muted-foreground">{row.height || "-"}</TableCell>;
|
||||
case "thickness":
|
||||
return <TableCell key={col.key} className="text-[13px] text-right font-mono text-muted-foreground">{row.thickness || "-"}</TableCell>;
|
||||
case "unit":
|
||||
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
|
||||
case "order_qty":
|
||||
@@ -1224,6 +1242,9 @@ export default function PurchaseOrderPage() {
|
||||
</TableHead>
|
||||
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
||||
<TableHead className="w-[60px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">가로</TableHead>
|
||||
<TableHead className="w-[60px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">세로</TableHead>
|
||||
<TableHead className="w-[60px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">두께</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">재질</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
@@ -1231,7 +1252,7 @@ export default function PurchaseOrderPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{itemSearchResults.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">검색 결과가 없어요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={9} className="text-center text-muted-foreground py-8">검색 결과가 없어요</TableCell></TableRow>
|
||||
) : itemSearchResults.map((item) => (
|
||||
<TableRow key={item.id} className={cn("cursor-pointer", itemSelectedMap.has(item.id) && "bg-primary/5")}
|
||||
onClick={() => setItemSelectedMap((prev) => {
|
||||
@@ -1244,6 +1265,9 @@ export default function PurchaseOrderPage() {
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] max-w-[130px]"><span className="block truncate" title={item.item_number}>{item.item_number}</span></TableCell>
|
||||
<TableCell className="text-sm max-w-[150px]"><span className="block truncate" title={item.item_name}>{item.item_name}</span></TableCell>
|
||||
<TableCell className="text-right text-[13px] font-mono">{item.width || "-"}</TableCell>
|
||||
<TableCell className="text-right text-[13px] font-mono">{item.height || "-"}</TableCell>
|
||||
<TableCell className="text-right text-[13px] font-mono">{item.thickness || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.size}</TableCell>
|
||||
<TableCell className="text-[13px]">{categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material}</TableCell>
|
||||
<TableCell className="text-[13px]">{categoryOptions["item_inventory_unit"]?.find((o) => o.code === item.inventory_unit)?.label || item.inventory_unit}</TableCell>
|
||||
|
||||
@@ -141,6 +141,9 @@ const FORM_FIELDS = [
|
||||
{ key: "item_name", label: "품명", type: "text", required: true },
|
||||
{ key: "division", label: "관리품목", type: "multi-category" },
|
||||
{ key: "type", label: "품목구분", type: "category" },
|
||||
{ key: "width", label: "가로", type: "text", placeholder: "숫자 입력 (예: 1000)" },
|
||||
{ key: "height", label: "세로", type: "text", placeholder: "숫자 입력 (예: 2000)" },
|
||||
{ key: "thickness", label: "두께", type: "text", placeholder: "숫자 입력 (예: 10)" },
|
||||
{ key: "size", label: "규격", type: "text" },
|
||||
{ key: "inventory_unit", label: "단위", type: "category" },
|
||||
{ key: "material", label: "재질", type: "category" },
|
||||
@@ -173,6 +176,9 @@ const formatNum = (val: any): string => {
|
||||
const ITEM_GRID_COLUMNS = [
|
||||
{ key: "item_number", label: "품번" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "width", label: "가로", align: "right" as const },
|
||||
{ key: "height", label: "세로", align: "right" as const },
|
||||
{ key: "thickness", label: "두께", align: "right" as const },
|
||||
{ key: "size", label: "규격" },
|
||||
{ key: "inventory_unit", label: "단위" },
|
||||
{ key: "standard_price", label: "기준단가/구매단가" },
|
||||
|
||||
@@ -606,7 +606,7 @@ export default function InspectionManagementPage() {
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-col gap-3 p-3 h-[calc(100vh-4rem)] overflow-auto">
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<div className="bg-card rounded-lg border">
|
||||
|
||||
@@ -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";
|
||||
@@ -34,12 +36,6 @@ const GRID_COLUMNS = [
|
||||
{ key: "is_active", label: "사용여부" },
|
||||
];
|
||||
|
||||
const INSPECTION_TYPES = [
|
||||
{ key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] },
|
||||
{ key: "outgoing_inspection", label: "출하검사", matchLabels: ["출하검사", "출고검사", "출하", "출고"] },
|
||||
{ key: "process_inspection", label: "공정검사", matchLabels: ["공정검사", "공정"] },
|
||||
{ key: "final_inspection", label: "최종검사", matchLabels: ["최종검사", "최종", "완제품검사"] },
|
||||
] as const;
|
||||
|
||||
type InspectionRow = {
|
||||
id: string;
|
||||
@@ -79,6 +75,15 @@ export default function ItemInspectionInfoPage() {
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
|
||||
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
// 검사유형 목록 (검사기준 카테고리 기반)
|
||||
const INSPECTION_TYPES = useMemo(() => {
|
||||
return inspTypeCatOptions.map((cat) => ({
|
||||
key: cat.code,
|
||||
label: cat.label,
|
||||
matchLabels: [cat.code, cat.label],
|
||||
}));
|
||||
}, [inspTypeCatOptions]);
|
||||
const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [judgmentCatOptions, setJudgmentCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspUnitCatOptions, setInspUnitCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
@@ -96,6 +101,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 +478,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 +729,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 +887,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);
|
||||
@@ -752,11 +995,11 @@ export default function ItemInspectionInfoPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{editMode ? "품목검사정보 수정" : "품목검사정보 등록"}</DialogTitle>
|
||||
<DialogDescription className="sr-only">품목검사정보를 등록합니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
|
||||
{/* 품목 정보 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">품목 정보</h4>
|
||||
@@ -918,7 +1161,7 @@ export default function ItemInspectionInfoPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}저장
|
||||
@@ -930,6 +1173,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
|
||||
ClipboardList, Package, Search, X, Settings2, GripVertical,
|
||||
ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
@@ -94,6 +95,12 @@ export default function JeilGlassOrderPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
// 전체 통계 (서버에서 별도 집계)
|
||||
const [totalStats, setTotalStats] = useState<{ totalAmount: number; totalQty: number }>({ totalAmount: 0, totalQty: 0 });
|
||||
// 좌측 페이지네이션
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [pageSizeInput, setPageSizeInput] = useState("20");
|
||||
const [selectedOrderNo, setSelectedOrderNo] = useState<string | null>(null);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
|
||||
@@ -168,7 +175,7 @@ export default function JeilGlassOrderPage() {
|
||||
}
|
||||
// 거래처
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, { page: 1, size: 500, autoFilter: true });
|
||||
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, { page: 1, size: 5000, autoFilter: true });
|
||||
const custs = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
optMap["partner_id"] = custs.map((c: any) => ({
|
||||
code: c.customer_code,
|
||||
@@ -198,7 +205,7 @@ export default function JeilGlassOrderPage() {
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
// 수주 목록 조회 (디테일 전체 → order_no 그룹핑)
|
||||
// 수주 목록 조회 (마스터 서버 페이징 → 디테일 조인)
|
||||
const fetchMasterOrders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -207,29 +214,36 @@ export default function JeilGlassOrderPage() {
|
||||
operator: f.operator,
|
||||
value: f.value,
|
||||
}));
|
||||
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
|
||||
// 1단계: 마스터 서버 페이징 조회
|
||||
const mRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||
page: currentPage, size: pageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
sort: { columnName: "order_no", order: "desc" },
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setAllDetails(rows);
|
||||
const masters = mRes.data?.data?.data || mRes.data?.data?.rows || [];
|
||||
const serverTotal = mRes.data?.data?.total || mRes.data?.data?.totalCount || masters.length;
|
||||
setTotalCount(serverTotal);
|
||||
|
||||
// 마스터 조회 (거래처 정보 확보)
|
||||
const orderNos = [...new Set(rows.map((r: any) => r.order_no).filter(Boolean))];
|
||||
let masterMap: Record<string, any> = {};
|
||||
// 2단계: 해당 페이지 마스터의 order_no로 디테일 조회
|
||||
const orderNos = masters.map((m: any) => m.order_no).filter(Boolean);
|
||||
let allRows: any[] = [];
|
||||
if (orderNos.length > 0) {
|
||||
try {
|
||||
const mRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||
page: 1, size: orderNos.length + 10,
|
||||
const dRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1, size: orderNos.length * 50,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "in", value: orderNos }] },
|
||||
autoFilter: true,
|
||||
sort: { columnName: "order_no", order: "desc" },
|
||||
});
|
||||
const masters = mRes.data?.data?.data || mRes.data?.data?.rows || [];
|
||||
for (const m of masters) masterMap[m.order_no] = m;
|
||||
allRows = dRes.data?.data?.data || dRes.data?.data?.rows || [];
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setAllDetails(allRows);
|
||||
|
||||
const masterMap: Record<string, any> = {};
|
||||
for (const m of masters) masterMap[m.order_no] = m;
|
||||
|
||||
// 거래처 코드 → 이름 변환
|
||||
const resolvePartner = (code: string) => {
|
||||
@@ -237,26 +251,28 @@ export default function JeilGlassOrderPage() {
|
||||
return categoryOptions["partner_id"]?.find((o) => o.code === code)?.label?.split(" (")[0] || code;
|
||||
};
|
||||
|
||||
// order_no 기준 집계
|
||||
// order_no 기준 집계 (마스터 기반으로 생성, 디테일 누적)
|
||||
const grouped: Record<string, any> = {};
|
||||
for (const row of rows) {
|
||||
const no = row.order_no;
|
||||
for (const m of masters) {
|
||||
const no = m.order_no;
|
||||
if (!no) continue;
|
||||
if (!grouped[no]) {
|
||||
const master = masterMap[no] || {};
|
||||
grouped[no] = {
|
||||
id: `master_${no}`,
|
||||
order_no: no,
|
||||
partner_name: resolvePartner(master.partner_id),
|
||||
item_count: 0,
|
||||
total_qty: 0,
|
||||
total_ship_qty: 0,
|
||||
total_balance: 0,
|
||||
total_amount: 0,
|
||||
due_date: row.due_date || "",
|
||||
status: master.status || "",
|
||||
};
|
||||
}
|
||||
grouped[no] = {
|
||||
id: `master_${no}`,
|
||||
order_no: no,
|
||||
partner_name: resolvePartner(m.partner_id),
|
||||
item_count: 0,
|
||||
total_qty: 0,
|
||||
total_ship_qty: 0,
|
||||
total_balance: 0,
|
||||
total_amount: 0,
|
||||
due_date: m.due_date || "",
|
||||
status: m.status || "",
|
||||
};
|
||||
}
|
||||
// 디테일 기준 집계
|
||||
for (const row of allRows) {
|
||||
const no = row.order_no;
|
||||
if (!no || !grouped[no]) continue;
|
||||
const g = grouped[no];
|
||||
g.item_count += 1;
|
||||
g.total_qty += parseFloat(row.qty) || 0;
|
||||
@@ -267,26 +283,59 @@ export default function JeilGlassOrderPage() {
|
||||
}
|
||||
const list = Object.values(grouped);
|
||||
setMasterOrders(list);
|
||||
setTotalCount(list.length);
|
||||
|
||||
// 전체 통계: DB에서 직접 SUM (size:99999 전체 조회 대신)
|
||||
try {
|
||||
const aggRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/aggregate`, {
|
||||
columns: [
|
||||
{ column: "qty", func: "sum" },
|
||||
{ column: "amount", func: "sum" },
|
||||
],
|
||||
autoFilter: true,
|
||||
});
|
||||
if (aggRes.data?.success) {
|
||||
setTotalStats({
|
||||
totalQty: Number(aggRes.data.data?.sum_qty) || 0,
|
||||
totalAmount: Number(aggRes.data.data?.sum_amount) || 0,
|
||||
});
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} catch (err) {
|
||||
console.error("수주 조회 실패:", err);
|
||||
toast.error("수주 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters, categoryOptions]);
|
||||
}, [searchFilters, categoryOptions, currentPage, pageSize]);
|
||||
|
||||
useEffect(() => { fetchMasterOrders(); }, [fetchMasterOrders]);
|
||||
|
||||
// 통계
|
||||
const stats = useMemo(() => {
|
||||
let totalAmount = 0, totalQty = 0;
|
||||
for (const m of masterOrders) {
|
||||
totalAmount += m.total_amount || 0;
|
||||
totalQty += m.total_qty || 0;
|
||||
// 서버 페이징 계산
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
const safePage = Math.min(Math.max(1, currentPage), totalPages);
|
||||
|
||||
const applyPageSize = () => {
|
||||
const n = parseInt(pageSizeInput, 10);
|
||||
if (!isNaN(n) && n >= 1) { setPageSize(n); setCurrentPage(1); }
|
||||
else setPageSizeInput(String(pageSize));
|
||||
};
|
||||
|
||||
const getPageNumbers = (): (number | "...")[] => {
|
||||
const pages: (number | "...")[] = [];
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (safePage > 3) pages.push("...");
|
||||
for (let i = Math.max(2, safePage - 1); i <= Math.min(totalPages - 1, safePage + 1); i++) pages.push(i);
|
||||
if (safePage < totalPages - 2) pages.push("...");
|
||||
pages.push(totalPages);
|
||||
}
|
||||
return { totalAmount, totalQty };
|
||||
}, [masterOrders]);
|
||||
return pages;
|
||||
};
|
||||
|
||||
// 통계 (전체 기준)
|
||||
const stats = totalStats;
|
||||
|
||||
// 우측: 선택된 수주 디테일 조회 (division 코드→라벨 변환)
|
||||
useEffect(() => {
|
||||
@@ -537,19 +586,27 @@ export default function JeilGlassOrderPage() {
|
||||
if (!code) return "";
|
||||
return categoryOptions["item_division"]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
const newRows = selected.map((item) => ({
|
||||
_id: `new_${Date.now()}_${Math.random()}`,
|
||||
_fromItemInfo: true,
|
||||
part_code: item.item_number || "",
|
||||
part_name: item.item_name || "",
|
||||
spec: item.size || "",
|
||||
division: item.division || "",
|
||||
_divisionLabel: resolveDivision(item.division),
|
||||
unit: resolveUnit(item.unit) || "",
|
||||
width: "", height: "", thickness: "", area: "",
|
||||
qty: "", unit_price: item.selling_price || item.standard_price || "", amount: "",
|
||||
due_date: "", memo: "",
|
||||
}));
|
||||
const newRows = selected.map((item) => {
|
||||
const w = parseFloat(item.width) || 0;
|
||||
const h = parseFloat(item.height) || 0;
|
||||
const autoArea = w > 0 && h > 0 ? String(Math.round((w * h / 1_000_000) * 10000) / 10000) : "";
|
||||
return {
|
||||
_id: `new_${Date.now()}_${Math.random()}`,
|
||||
_fromItemInfo: true,
|
||||
part_code: item.item_number || "",
|
||||
part_name: item.item_name || "",
|
||||
spec: item.size || "",
|
||||
division: item.division || "",
|
||||
_divisionLabel: resolveDivision(item.division),
|
||||
unit: resolveUnit(item.unit) || "",
|
||||
width: item.width || "",
|
||||
height: item.height || "",
|
||||
thickness: item.thickness || "",
|
||||
area: item.area || autoArea,
|
||||
qty: "", unit_price: item.selling_price || item.standard_price || "", amount: "",
|
||||
due_date: "", memo: "",
|
||||
};
|
||||
});
|
||||
setModalDetailRows((prev) => [...prev, ...newRows]);
|
||||
setItemSelectOpen(false);
|
||||
setItemCheckedIds(new Set());
|
||||
@@ -758,6 +815,7 @@ export default function JeilGlassOrderPage() {
|
||||
data={masterOrders}
|
||||
loading={loading}
|
||||
showCheckbox
|
||||
showPagination={false}
|
||||
checkedIds={checkedIds}
|
||||
onCheckedChange={setCheckedIds}
|
||||
onRowClick={handleMasterRowClick}
|
||||
@@ -765,6 +823,38 @@ export default function JeilGlassOrderPage() {
|
||||
tableName={MASTER_TABLE}
|
||||
emptyMessage="등록된 수주가 없습니다"
|
||||
/>
|
||||
{/* 페이지네이션 */}
|
||||
<div className="flex items-center justify-between border-t px-4 py-2 text-xs text-muted-foreground shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>전체 <span className="font-medium text-foreground">{totalCount}</span>건</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input type="number" min={1} value={pageSizeInput}
|
||||
onChange={(e) => setPageSizeInput(e.target.value)}
|
||||
onBlur={applyPageSize}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyPageSize(); } }}
|
||||
className="h-7 w-16 text-center text-xs" />
|
||||
<span>건씩</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => setCurrentPage(1)} disabled={safePage === 1} className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => setCurrentPage((p) => Math.max(1, p - 1))} disabled={safePage === 1} className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
|
||||
{getPageNumbers().map((page, idx) =>
|
||||
page === "..." ? (
|
||||
<span key={`dot-${idx}`} className="h-7 w-7 flex items-center justify-center text-muted-foreground">...</span>
|
||||
) : (
|
||||
<button key={page} onClick={() => setCurrentPage(page as number)}
|
||||
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||
page === safePage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>
|
||||
{page}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} disabled={safePage === totalPages} className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => setCurrentPage(totalPages)} disabled={safePage === totalPages} className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
<span>{safePage} / {totalPages}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
@@ -1080,13 +1170,16 @@ export default function JeilGlassOrderPage() {
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]">품목코드</TableHead>
|
||||
<TableHead className="min-w-[150px]">품명</TableHead>
|
||||
<TableHead className="w-[70px] text-right">가로</TableHead>
|
||||
<TableHead className="w-[70px] text-right">세로</TableHead>
|
||||
<TableHead className="w-[60px] text-right">두께</TableHead>
|
||||
<TableHead className="w-[100px]">규격</TableHead>
|
||||
<TableHead className="w-[60px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{itemSearchResults.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">검색 결과가 없습니다</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8} className="text-center text-muted-foreground py-8">검색 결과가 없습니다</TableCell></TableRow>
|
||||
) : itemSearchResults.map((item) => (
|
||||
<TableRow key={item.id} className={cn("cursor-pointer", itemCheckedIds.has(item.id) && "bg-primary/5")}
|
||||
onClick={() => setItemCheckedIds((prev) => {
|
||||
@@ -1097,6 +1190,9 @@ export default function JeilGlassOrderPage() {
|
||||
<TableCell className="text-center"><input type="checkbox" checked={itemCheckedIds.has(item.id)} readOnly /></TableCell>
|
||||
<TableCell className="text-xs">{item.item_number}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_name}</TableCell>
|
||||
<TableCell className="text-xs text-right font-mono">{item.width || "-"}</TableCell>
|
||||
<TableCell className="text-xs text-right font-mono">{item.height || "-"}</TableCell>
|
||||
<TableCell className="text-xs text-right font-mono">{item.thickness || "-"}</TableCell>
|
||||
<TableCell className="text-xs">{item.size}</TableCell>
|
||||
<TableCell className="text-xs">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -148,6 +148,9 @@ const formatNum = (val: any): string => {
|
||||
const ITEM_GRID_COLUMNS = [
|
||||
{ key: "item_number", label: "품번" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "width", label: "가로", align: "right" as const },
|
||||
{ key: "height", label: "세로", align: "right" as const },
|
||||
{ key: "thickness", label: "두께", align: "right" as const },
|
||||
{ key: "size", label: "규격" },
|
||||
{ key: "inventory_unit", label: "단위" },
|
||||
{ key: "standard_price", label: "기준단가" },
|
||||
@@ -161,6 +164,9 @@ const FORM_FIELDS = [
|
||||
{ key: "item_name", label: "품명", type: "text", required: true },
|
||||
{ key: "division", label: "관리품목", type: "multi-category" },
|
||||
{ key: "type", label: "품목구분", type: "category" },
|
||||
{ key: "width", label: "가로", type: "text", placeholder: "숫자 입력 (예: 1000)" },
|
||||
{ key: "height", label: "세로", type: "text", placeholder: "숫자 입력 (예: 2000)" },
|
||||
{ key: "thickness", label: "두께", type: "text", placeholder: "숫자 입력 (예: 10)" },
|
||||
{ key: "size", label: "규격", type: "text" },
|
||||
{ key: "inventory_unit", label: "단위", type: "category" },
|
||||
{ key: "material", label: "재질", type: "category" },
|
||||
@@ -1169,6 +1175,9 @@ export default function SalesItemPage() {
|
||||
const itemColumns: EDataTableColumn[] = [
|
||||
{ key: "item_number", label: "품번", width: "w-[110px]" },
|
||||
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
|
||||
{ key: "width", label: "가로", width: "w-[70px]", align: "right" },
|
||||
{ key: "height", label: "세로", width: "w-[70px]", align: "right" },
|
||||
{ key: "thickness", label: "두께", width: "w-[70px]", align: "right" },
|
||||
{ key: "size", label: "규격", width: "w-[80px]" },
|
||||
{ key: "inventory_unit", label: "단위", width: "w-[60px]" },
|
||||
{ key: "standard_price", label: "기준단가", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { MenuProvider } from "@/contexts/MenuContext";
|
||||
import { MessengerProvider } from "@/contexts/MessengerContext";
|
||||
import { AppLayout } from "@/components/layout/AppLayout";
|
||||
import { ApprovalGlobalListener } from "@/components/approval/ApprovalGlobalListener";
|
||||
import { ApprovalDetailModal } from "@/components/approval/ApprovalDetailModal";
|
||||
import { MessengerFAB } from "@/components/messenger/MessengerFAB";
|
||||
import { MessengerModal } from "@/components/messenger/MessengerModal";
|
||||
|
||||
@@ -13,6 +14,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
||||
<MessengerProvider>
|
||||
<AppLayout>{children}</AppLayout>
|
||||
<ApprovalGlobalListener />
|
||||
<ApprovalDetailModal />
|
||||
<MessengerFAB />
|
||||
<MessengerModal />
|
||||
</MessengerProvider>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { FileCheck, Menu, Users, Bell, FileText, Layout, Server, Shield, Calendar } from "lucide-react";
|
||||
|
||||
const quickAccessItems = [
|
||||
@@ -16,6 +17,7 @@ const quickAccessItems = [
|
||||
export default function MainPage() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const { openTab } = useTabStore();
|
||||
|
||||
const userName = user?.userName || "사용자";
|
||||
const today = new Date();
|
||||
@@ -40,7 +42,7 @@ export default function MainPage() {
|
||||
return (
|
||||
<button
|
||||
key={item.href}
|
||||
onClick={() => router.push(item.href)}
|
||||
onClick={() => openTab({ type: "admin", title: item.label, adminUrl: item.href })}
|
||||
className="group flex flex-col items-center gap-2.5 rounded-lg border bg-card p-4 transition-all hover:shadow-md"
|
||||
>
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${item.color} transition-transform group-hover:scale-105`}>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { FileCheck, Menu, Users, Bell, FileText, Layout, Server, Shield, Calendar, ArrowRight } from "lucide-react";
|
||||
|
||||
const quickAccessItems = [
|
||||
@@ -16,6 +17,7 @@ const quickAccessItems = [
|
||||
export default function MainHomePage() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const { openTab } = useTabStore();
|
||||
|
||||
const userName = user?.userName || "사용자";
|
||||
const today = new Date();
|
||||
@@ -40,7 +42,7 @@ export default function MainHomePage() {
|
||||
return (
|
||||
<button
|
||||
key={item.href}
|
||||
onClick={() => router.push(item.href)}
|
||||
onClick={() => openTab({ type: "admin", title: item.label, adminUrl: item.href })}
|
||||
className="group flex flex-col items-center gap-2.5 rounded-lg border bg-card p-4 transition-all hover:shadow-md"
|
||||
>
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${item.color} transition-transform group-hover:scale-105`}>
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Loader2, CheckCircle2, XCircle, Clock, FileCheck2 } from "lucide-react";
|
||||
import {
|
||||
getApprovalRequest,
|
||||
processApprovalLine,
|
||||
cancelApprovalRequest,
|
||||
type ApprovalRequest,
|
||||
} from "@/lib/api/approval";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||
requested: { label: "요청됨", variant: "secondary" },
|
||||
in_progress: { label: "진행 중", variant: "default" },
|
||||
approved: { label: "승인됨", variant: "outline" },
|
||||
rejected: { label: "반려됨", variant: "destructive" },
|
||||
cancelled: { label: "취소됨", variant: "secondary" },
|
||||
post_pending: { label: "후결대기", variant: "secondary" },
|
||||
};
|
||||
|
||||
const lineStatusConfig: Record<string, { label: string; icon: React.ReactNode }> = {
|
||||
waiting: { label: "대기", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
|
||||
pending: { label: "진행 중", icon: <Clock className="h-3 w-3 text-primary" /> },
|
||||
approved: { label: "승인", icon: <CheckCircle2 className="h-3 w-3 text-emerald-600" /> },
|
||||
rejected: { label: "반려", icon: <XCircle className="h-3 w-3 text-destructive" /> },
|
||||
skipped: { label: "건너뜀", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
|
||||
};
|
||||
|
||||
export interface ApprovalDetailEventDetail {
|
||||
requestId: number;
|
||||
}
|
||||
|
||||
export const ApprovalDetailModal: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [request, setRequest] = useState<ApprovalRequest | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [comment, setComment] = useState("");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
|
||||
// 글로벌 이벤트 수신
|
||||
useEffect(() => {
|
||||
const handler = async (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail as ApprovalDetailEventDetail;
|
||||
if (!detail?.requestId) return;
|
||||
setOpen(true);
|
||||
setLoading(true);
|
||||
const res = await getApprovalRequest(detail.requestId);
|
||||
setLoading(false);
|
||||
if (res.success && res.data) setRequest(res.data);
|
||||
else setRequest(null);
|
||||
};
|
||||
window.addEventListener("open-approval-detail-modal", handler);
|
||||
return () => window.removeEventListener("open-approval-detail-modal", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setComment("");
|
||||
setRequest(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 내가 처리할 결재 라인 ID 찾기
|
||||
const pendingLineId = request?.lines?.find(
|
||||
(l) => l.approver_id === user?.userId && l.status === "pending"
|
||||
)?.line_id;
|
||||
|
||||
const handleProcess = async (action: "approved" | "rejected") => {
|
||||
if (!pendingLineId) return;
|
||||
setIsProcessing(true);
|
||||
const res = await processApprovalLine(pendingLineId, { action, comment: comment.trim() || undefined });
|
||||
setIsProcessing(false);
|
||||
if (res.success) {
|
||||
setOpen(false);
|
||||
// 호출한 페이지에 새로고침 알림
|
||||
window.dispatchEvent(new CustomEvent("approval-processed", { detail: { requestId: request?.request_id, action } }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!request) return;
|
||||
setIsCancelling(true);
|
||||
const res = await cancelApprovalRequest(request.request_id);
|
||||
setIsCancelling(false);
|
||||
if (res.success) {
|
||||
setOpen(false);
|
||||
window.dispatchEvent(new CustomEvent("approval-processed", { detail: { requestId: request.request_id, action: "cancelled" } }));
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const statusInfo = request ? (statusConfig[request.status] || { label: request.status, variant: "secondary" as const }) : null;
|
||||
const isRequester = request?.requester_id === user?.userId;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
{loading || !request ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<FileCheck2 className="h-5 w-5" />
|
||||
{request.title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{statusInfo && (
|
||||
<Badge variant={statusInfo.variant} className="mr-2">
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
)}
|
||||
요청자: {request.requester_name || request.requester_id}
|
||||
{request.requester_dept ? ` (${request.requester_dept})` : ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{request.description && (
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1 text-xs font-medium">결재 사유</p>
|
||||
<p className="rounded-md bg-muted p-3 text-xs sm:text-sm whitespace-pre-line">{request.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-2 text-xs font-medium">결재선</p>
|
||||
<div className="space-y-2">
|
||||
{(request.lines || []).map((line) => {
|
||||
const lineStatus = lineStatusConfig[line.status] || { label: line.status, icon: null };
|
||||
return (
|
||||
<div key={line.line_id} className="flex items-start justify-between rounded-md border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{lineStatus.icon}
|
||||
<div>
|
||||
<p className="text-xs font-medium sm:text-sm">
|
||||
{line.approver_label || `${line.step_order}차 결재`} — {line.approver_name || line.approver_id}
|
||||
</p>
|
||||
{line.approver_position && (
|
||||
<p className="text-muted-foreground text-[10px] sm:text-xs">{line.approver_position}</p>
|
||||
)}
|
||||
{line.comment && (
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">의견: {line.comment}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-[10px] sm:text-xs">{lineStatus.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingLineId && (
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1 text-xs font-medium">결재 의견 (선택사항)</p>
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="결재 의견을 입력하세요"
|
||||
className="min-h-[60px] text-xs sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-wrap gap-2 sm:gap-1">
|
||||
{isRequester && (request.status === "requested" || request.status === "in_progress") && !pendingLineId && (
|
||||
<Button variant="outline" size="sm" onClick={handleCancel} disabled={isCancelling} className="h-8 text-xs">
|
||||
{isCancelling ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : null}
|
||||
회수
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||
닫기
|
||||
</Button>
|
||||
{pendingLineId && (
|
||||
<>
|
||||
<Button variant="destructive" onClick={() => handleProcess("rejected")} disabled={isProcessing} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||
{isProcessing ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <XCircle className="mr-1 h-3 w-3" />}
|
||||
반려
|
||||
</Button>
|
||||
<Button onClick={() => handleProcess("approved")} disabled={isProcessing} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||
{isProcessing ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <CheckCircle2 className="mr-1 h-3 w-3" />}
|
||||
승인
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApprovalDetailModal;
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
type ApprovalLineTemplate,
|
||||
} from "@/lib/api/approval";
|
||||
import { getUserList } from "@/lib/api/user";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 결재 방식
|
||||
type ApprovalMode = "sequential" | "parallel";
|
||||
@@ -54,6 +56,8 @@ export interface ApprovalModalEventDetail {
|
||||
definitionId?: number;
|
||||
screenId?: number;
|
||||
buttonComponentId?: string;
|
||||
defaultTitle?: string;
|
||||
defaultDescription?: string;
|
||||
}
|
||||
|
||||
interface ApprovalRequestModalProps {
|
||||
@@ -84,6 +88,7 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||
onOpenChange,
|
||||
eventDetail,
|
||||
}) => {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [approvalMode, setApprovalMode] = useState<ApprovalMode>("sequential");
|
||||
@@ -118,8 +123,12 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||
setAllUsers([]);
|
||||
setSelectedTemplateId(null);
|
||||
setShowTemplatePopover(false);
|
||||
} else if (eventDetail) {
|
||||
// 모달 열릴 때 defaultTitle/defaultDescription 적용
|
||||
if (eventDetail.defaultTitle) setTitle(eventDetail.defaultTitle);
|
||||
if (eventDetail.defaultDescription) setDescription(eventDetail.defaultDescription);
|
||||
}
|
||||
}, [open]);
|
||||
}, [open, eventDetail]);
|
||||
|
||||
// 모달 열릴 때 템플릿 + 사용자 목록 로드
|
||||
useEffect(() => {
|
||||
@@ -207,6 +216,16 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||
);
|
||||
|
||||
const addApprover = (user: UserSearchResult) => {
|
||||
// 본인 결재자 추가 차단 (자기결재 모드 제외)
|
||||
if (approvalType !== "self" && currentUser?.userId && user.userId === currentUser.userId) {
|
||||
toast.error("본인은 결재선에 추가할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
// 이미 추가된 경우: 제거 (토글 방식)
|
||||
if (approvers.some((a) => a.user_id === user.userId)) {
|
||||
setApprovers((prev) => prev.filter((a) => a.user_id !== user.userId));
|
||||
return;
|
||||
}
|
||||
setApprovers((prev) => [
|
||||
...prev,
|
||||
{
|
||||
@@ -217,7 +236,7 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||
dept_name: user.deptName || "",
|
||||
},
|
||||
]);
|
||||
setComboboxOpen(false);
|
||||
// 팝오버는 닫지 않음 - 연속 선택 가능
|
||||
};
|
||||
|
||||
const removeApprover = (id: string) => {
|
||||
@@ -509,18 +528,31 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||
검색 결과가 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableUsers.map((user) => (
|
||||
{availableUsers.map((user) => {
|
||||
const selectedIdx = approvers.findIndex((a) => a.user_id === user.userId);
|
||||
const isSelected = selectedIdx >= 0;
|
||||
return (
|
||||
<CommandItem
|
||||
key={user.userId}
|
||||
value={`${user.userName} ${user.userId} ${user.deptName || ""} ${user.positionName || ""}`}
|
||||
onSelect={() => addApprover(user)}
|
||||
className="flex cursor-pointer items-center gap-3 px-3 py-2 text-xs sm:text-sm"
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-3 px-3 py-2 text-xs sm:text-sm",
|
||||
isSelected && "bg-primary/5"
|
||||
)}
|
||||
>
|
||||
<div className="bg-muted flex h-7 w-7 shrink-0 items-center justify-center rounded-full">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
<div className={cn(
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full",
|
||||
isSelected ? "bg-primary text-primary-foreground font-bold" : "bg-muted"
|
||||
)}>
|
||||
{isSelected ? (
|
||||
<span className="text-[11px]">{selectedIdx + 1}</span>
|
||||
) : (
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">
|
||||
<p className={cn("truncate font-medium", isSelected && "text-primary")}>
|
||||
{user.userName}
|
||||
<span className="text-muted-foreground ml-1 text-[10px]">
|
||||
({user.userId})
|
||||
@@ -530,9 +562,14 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||
{[user.deptName, user.positionName].filter(Boolean).join(" / ") || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<Plus className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
{isSelected ? (
|
||||
<X className="text-destructive h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<Plus className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface AddressSearchButtonProps {
|
||||
onComplete: (data: {
|
||||
address: string; // 선택된 주소 (도로명 또는 지번)
|
||||
roadAddress: string; // 도로명주소
|
||||
jibunAddress: string; // 지번주소
|
||||
zonecode: string; // 우편번호
|
||||
buildingName?: string; // 건물명
|
||||
}) => void;
|
||||
size?: "sm" | "default" | "lg";
|
||||
variant?: "default" | "outline" | "secondary" | "ghost";
|
||||
className?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
daum?: any;
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT_SRC = "//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js";
|
||||
|
||||
// 스크립트 로드 (1회)
|
||||
function loadScript(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof window === "undefined") return reject(new Error("SSR"));
|
||||
if (window.daum?.Postcode) return resolve();
|
||||
const existing = document.querySelector(`script[src="${SCRIPT_SRC}"]`) as HTMLScriptElement | null;
|
||||
if (existing) {
|
||||
existing.addEventListener("load", () => resolve());
|
||||
existing.addEventListener("error", () => reject(new Error("load failed")));
|
||||
return;
|
||||
}
|
||||
const script = document.createElement("script");
|
||||
script.src = SCRIPT_SRC;
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error("load failed"));
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
export const AddressSearchButton: React.FC<AddressSearchButtonProps> = ({
|
||||
onComplete,
|
||||
size = "sm",
|
||||
variant = "outline",
|
||||
className,
|
||||
label = "주소 검색",
|
||||
}) => {
|
||||
const handleClick = useCallback(async () => {
|
||||
try {
|
||||
await loadScript();
|
||||
if (!window.daum?.Postcode) {
|
||||
toast.error("주소 검색 서비스를 불러올 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
new window.daum.Postcode({
|
||||
oncomplete: (data: any) => {
|
||||
const road = data.roadAddress || "";
|
||||
const jibun = data.jibunAddress || data.autoJibunAddress || "";
|
||||
const picked = road || jibun;
|
||||
onComplete({
|
||||
address: picked,
|
||||
roadAddress: road,
|
||||
jibunAddress: jibun,
|
||||
zonecode: data.zonecode || "",
|
||||
buildingName: data.buildingName || "",
|
||||
});
|
||||
},
|
||||
}).open();
|
||||
} catch {
|
||||
toast.error("주소 검색 스크립트 로드에 실패했습니다.");
|
||||
}
|
||||
}, [onComplete]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={size}
|
||||
onClick={handleClick}
|
||||
className={className}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddressSearchButton;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,532 @@
|
||||
/**
|
||||
* 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,
|
||||
errorStyle: "stop",
|
||||
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,
|
||||
errorStyle: "stop",
|
||||
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,
|
||||
errorStyle: "stop",
|
||||
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,
|
||||
errorStyle: "stop",
|
||||
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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -777,7 +777,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>프로필</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
|
||||
<DropdownMenuItem onClick={() => openTab({ type: "admin", title: "결재함", adminUrl: "/admin/approvalBox" })}>
|
||||
<FileCheck className="mr-2 h-4 w-4" />
|
||||
<span>결재함</span>
|
||||
</DropdownMenuItem>
|
||||
@@ -1071,7 +1071,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>프로필</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
|
||||
<DropdownMenuItem onClick={() => openTab({ type: "admin", title: "결재함", adminUrl: "/admin/approvalBox" })}>
|
||||
<FileCheck className="mr-2 h-4 w-4" />
|
||||
<span>결재함</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { LogOut, FileCheck, Monitor, User } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
|
||||
interface UserDropdownProps {
|
||||
user: any;
|
||||
@@ -23,6 +24,7 @@ interface UserDropdownProps {
|
||||
*/
|
||||
export function UserDropdown({ user, onProfileClick, onPopModeClick, onLogout }: UserDropdownProps) {
|
||||
const router = useRouter();
|
||||
const { openTab } = useTabStore();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
@@ -82,7 +84,7 @@ export function UserDropdown({ user, onProfileClick, onPopModeClick, onLogout }:
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>프로필</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
|
||||
<DropdownMenuItem onClick={() => openTab({ type: "admin", title: "결재함", adminUrl: "/admin/approvalBox" })}>
|
||||
<FileCheck className="mr-2 h-4 w-4" />
|
||||
<span>결재함</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
Reference in New Issue
Block a user