Merge branch 'main' into feature/screen-management
This commit is contained in:
commit
4828488c72
|
|
@ -901,13 +901,23 @@ export async function addTableData(
|
|||
}
|
||||
|
||||
// 데이터 추가
|
||||
await tableManagementService.addTableData(tableName, data);
|
||||
const result = await tableManagementService.addTableData(tableName, data);
|
||||
|
||||
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
// 무시된 컬럼이 있으면 경고 정보 포함
|
||||
const response: ApiResponse<{
|
||||
skippedColumns?: string[];
|
||||
savedColumns?: string[];
|
||||
}> = {
|
||||
success: true,
|
||||
message: "테이블 데이터를 성공적으로 추가했습니다.",
|
||||
message: result.skippedColumns.length > 0
|
||||
? `테이블 데이터를 추가했습니다. (무시된 컬럼 ${result.skippedColumns.length}개: ${result.skippedColumns.join(", ")})`
|
||||
: "테이블 데이터를 성공적으로 추가했습니다.",
|
||||
data: {
|
||||
skippedColumns: result.skippedColumns.length > 0 ? result.skippedColumns : undefined,
|
||||
savedColumns: result.savedColumns,
|
||||
},
|
||||
};
|
||||
|
||||
res.status(201).json(response);
|
||||
|
|
|
|||
|
|
@ -2261,11 +2261,12 @@ export class TableManagementService {
|
|||
|
||||
/**
|
||||
* 테이블에 데이터 추가
|
||||
* @returns 무시된 컬럼 정보 (디버깅용)
|
||||
*/
|
||||
async addTableData(
|
||||
tableName: string,
|
||||
data: Record<string, any>
|
||||
): Promise<void> {
|
||||
): Promise<{ skippedColumns: string[]; savedColumns: string[] }> {
|
||||
try {
|
||||
logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`);
|
||||
logger.info(`추가할 데이터:`, data);
|
||||
|
|
@ -2296,10 +2297,41 @@ export class TableManagementService {
|
|||
logger.info(`created_date 자동 추가: ${data.created_date}`);
|
||||
}
|
||||
|
||||
// 컬럼명과 값을 분리하고 타입에 맞게 변환
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data).map((value, index) => {
|
||||
const columnName = columns[index];
|
||||
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
|
||||
const skippedColumns: string[] = [];
|
||||
const existingColumns = Object.keys(data).filter((col) => {
|
||||
const exists = columnTypeMap.has(col);
|
||||
if (!exists) {
|
||||
skippedColumns.push(col);
|
||||
}
|
||||
return exists;
|
||||
});
|
||||
|
||||
// 무시된 컬럼이 있으면 경고 로그 출력
|
||||
if (skippedColumns.length > 0) {
|
||||
logger.warn(
|
||||
`⚠️ [${tableName}] 테이블에 존재하지 않는 컬럼 ${skippedColumns.length}개 무시됨: ${skippedColumns.join(", ")}`
|
||||
);
|
||||
logger.warn(
|
||||
`⚠️ [${tableName}] 무시된 컬럼 상세:`,
|
||||
skippedColumns.map((col) => ({ column: col, value: data[col] }))
|
||||
);
|
||||
}
|
||||
|
||||
if (existingColumns.length === 0) {
|
||||
throw new Error(
|
||||
`저장할 유효한 컬럼이 없습니다. 테이블: ${tableName}, 전달된 컬럼: ${Object.keys(data).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ [${tableName}] 저장될 컬럼 ${existingColumns.length}개: ${existingColumns.join(", ")}`
|
||||
);
|
||||
|
||||
// 컬럼명과 값을 분리하고 타입에 맞게 변환 (존재하는 컬럼만)
|
||||
const columns = existingColumns;
|
||||
const values = columns.map((columnName) => {
|
||||
const value = data[columnName];
|
||||
const dataType = columnTypeMap.get(columnName) || "text";
|
||||
const convertedValue = this.convertValueForPostgreSQL(value, dataType);
|
||||
logger.info(
|
||||
|
|
@ -2355,6 +2387,12 @@ export class TableManagementService {
|
|||
await query(insertQuery, values);
|
||||
|
||||
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
|
||||
|
||||
// 무시된 컬럼과 저장된 컬럼 정보 반환
|
||||
return {
|
||||
skippedColumns,
|
||||
savedColumns: existingColumns,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`테이블 데이터 추가 오류: ${tableName}`, error);
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -107,10 +107,10 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
const dbTableName = config.dbTableName || "vehicles";
|
||||
const dbKeyField = config.dbKeyField || "user_id";
|
||||
|
||||
// 기본 옵션 (포항/광양)
|
||||
// 기본 옵션 (포항/광양) - 한글로 저장
|
||||
const DEFAULT_OPTIONS: LocationOption[] = [
|
||||
{ value: "pohang", label: "포항" },
|
||||
{ value: "gwangyang", label: "광양" },
|
||||
{ value: "포항", label: "포항" },
|
||||
{ value: "광양", label: "광양" },
|
||||
];
|
||||
|
||||
// 상태
|
||||
|
|
|
|||
|
|
@ -26,9 +26,9 @@ export const LocationSwapSelectorDefinition = createComponentDefinition({
|
|||
labelField: "location_name", // 표시 필드
|
||||
codeCategory: "", // 코드 관리 카테고리 (type이 "code"일 때)
|
||||
staticOptions: [
|
||||
{ value: "pohang", label: "포항" },
|
||||
{ value: "gwangyang", label: "광양" },
|
||||
], // 정적 옵션 (type이 "static"일 때)
|
||||
{ value: "포항", label: "포항" },
|
||||
{ value: "광양", label: "광양" },
|
||||
], // 정적 옵션 (type이 "static"일 때) - 한글로 저장
|
||||
},
|
||||
// 필드 매핑
|
||||
departureField: "departure", // 출발지 저장 필드
|
||||
|
|
|
|||
|
|
@ -432,10 +432,25 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
const groups = componentConfig.fieldGroups || [];
|
||||
const additionalFields = componentConfig.additionalFields || [];
|
||||
|
||||
itemsList.forEach((item) => {
|
||||
itemsList.forEach((item, itemIndex) => {
|
||||
// 각 그룹의 엔트리 배열들을 준비
|
||||
const groupEntriesArrays: GroupEntry[][] = groups.map((group) => item.fieldGroups[group.id] || []);
|
||||
|
||||
// 🆕 모든 그룹이 비어있는지 확인
|
||||
const allGroupsEmpty = groupEntriesArrays.every((arr) => arr.length === 0);
|
||||
|
||||
if (allGroupsEmpty) {
|
||||
// 🆕 모든 그룹이 비어있으면 품목 기본 정보만으로 레코드 생성
|
||||
// (거래처 품번/품명, 기간별 단가 없이도 저장 가능)
|
||||
console.log("📝 [generateCartesianProduct] 모든 그룹이 비어있음 - 품목 기본 레코드 생성", {
|
||||
itemIndex,
|
||||
itemId: item.id,
|
||||
});
|
||||
// 빈 객체를 추가하면 parentKeys와 합쳐져서 기본 레코드가 됨
|
||||
allRecords.push({});
|
||||
return;
|
||||
}
|
||||
|
||||
// Cartesian Product 재귀 함수
|
||||
const cartesian = (arrays: GroupEntry[][], currentIndex: number, currentCombination: Record<string, any>) => {
|
||||
if (currentIndex === arrays.length) {
|
||||
|
|
@ -446,7 +461,8 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
|
||||
const currentGroupEntries = arrays[currentIndex];
|
||||
if (currentGroupEntries.length === 0) {
|
||||
// 현재 그룹에 데이터가 없으면 빈 조합으로 다음 그룹 진행
|
||||
// 🆕 현재 그룹에 데이터가 없으면 빈 조합으로 다음 그룹 진행
|
||||
// (그룹이 비어있어도 다른 그룹의 데이터로 레코드 생성)
|
||||
cartesian(arrays, currentIndex + 1, currentCombination);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2248,9 +2248,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후)
|
||||
const startEditingRef = useRef<() => void>(() => {});
|
||||
|
||||
// 🆕 각 컬럼의 고유값 목록 계산
|
||||
// 🆕 각 컬럼의 고유값 목록 계산 (라벨 포함)
|
||||
const columnUniqueValues = useMemo(() => {
|
||||
const result: Record<string, string[]> = {};
|
||||
const result: Record<string, Array<{ value: string; label: string }>> = {};
|
||||
|
||||
if (data.length === 0) return result;
|
||||
|
||||
|
|
@ -2258,16 +2258,34 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
if (column.columnName === "__checkbox__") return;
|
||||
|
||||
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
||||
const values = new Set<string>();
|
||||
// 라벨 컬럼 후보들 (백엔드에서 _name, _label, _value_label 등으로 반환할 수 있음)
|
||||
const labelColumnCandidates = [
|
||||
`${column.columnName}_name`, // 예: division_name
|
||||
`${column.columnName}_label`, // 예: division_label
|
||||
`${column.columnName}_value_label`, // 예: division_value_label
|
||||
];
|
||||
const valuesMap = new Map<string, string>(); // value -> label
|
||||
|
||||
data.forEach((row) => {
|
||||
const val = row[mappedColumnName];
|
||||
if (val !== null && val !== undefined && val !== "") {
|
||||
values.add(String(val));
|
||||
const valueStr = String(val);
|
||||
// 라벨 컬럼 후보들 중 값이 있는 것 사용, 없으면 원본 값 사용
|
||||
let label = valueStr;
|
||||
for (const labelCol of labelColumnCandidates) {
|
||||
if (row[labelCol] && row[labelCol] !== "") {
|
||||
label = String(row[labelCol]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
valuesMap.set(valueStr, label);
|
||||
}
|
||||
});
|
||||
|
||||
result[column.columnName] = Array.from(values).sort();
|
||||
// value-label 쌍으로 저장하고 라벨 기준 정렬
|
||||
result[column.columnName] = Array.from(valuesMap.entries())
|
||||
.map(([value, label]) => ({ value, label }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
});
|
||||
|
||||
return result;
|
||||
|
|
@ -5756,16 +5774,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
)}
|
||||
</div>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => {
|
||||
const isSelected = headerFilters[column.columnName]?.has(val);
|
||||
{columnUniqueValues[column.columnName]?.slice(0, 50).map((item) => {
|
||||
const isSelected = headerFilters[column.columnName]?.has(item.value);
|
||||
return (
|
||||
<div
|
||||
key={val}
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs",
|
||||
isSelected && "bg-primary/10",
|
||||
)}
|
||||
onClick={() => toggleHeaderFilter(column.columnName, val)}
|
||||
onClick={() => toggleHeaderFilter(column.columnName, item.value)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -5775,7 +5793,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
>
|
||||
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
|
||||
</div>
|
||||
<span className="truncate">{val || "(빈 값)"}</span>
|
||||
<span className="truncate">{item.label || "(빈 값)"}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -996,38 +996,27 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
|
||||
// 🆕 루트 레벨 formData에서 RepeaterFieldGroup에 전달할 공통 필드 추출
|
||||
// 주문번호, 발주번호 등 마스터-디테일 관계에서 필요한 필드만 명시적으로 지정
|
||||
const masterDetailFields = [
|
||||
// 번호 필드
|
||||
"order_no", // 발주번호
|
||||
"sales_order_no", // 수주번호
|
||||
"shipment_no", // 출하번호
|
||||
"receipt_no", // 입고번호
|
||||
"work_order_no", // 작업지시번호
|
||||
// 거래처 필드
|
||||
"supplier_code", // 공급처 코드
|
||||
"supplier_name", // 공급처 이름
|
||||
"customer_code", // 고객 코드
|
||||
"customer_name", // 고객 이름
|
||||
// 날짜 필드
|
||||
"order_date", // 발주일
|
||||
"sales_date", // 수주일
|
||||
"shipment_date", // 출하일
|
||||
"receipt_date", // 입고일
|
||||
"due_date", // 납기일
|
||||
// 담당자/메모 필드
|
||||
"manager", // 담당자
|
||||
"memo", // 메모
|
||||
"remark", // 비고
|
||||
];
|
||||
// 규칙 기반 필터링: 하드코딩 대신 패턴으로 제외할 필드를 정의
|
||||
for (const [fieldName, value] of Object.entries(context.formData)) {
|
||||
// 제외 규칙 1: comp_로 시작하는 필드 (하위 항목 배열)
|
||||
if (fieldName.startsWith("comp_")) continue;
|
||||
// 제외 규칙 2: _numberingRuleId로 끝나는 필드 (채번 규칙 메타 정보)
|
||||
if (fieldName.endsWith("_numberingRuleId")) continue;
|
||||
// 제외 규칙 3: _로 시작하는 필드 (내부 메타 필드)
|
||||
if (fieldName.startsWith("_")) continue;
|
||||
// 제외 규칙 4: 배열 타입 (하위 항목 데이터)
|
||||
if (Array.isArray(value)) continue;
|
||||
// 제외 규칙 5: 객체 타입 (복잡한 구조 데이터) - null 제외
|
||||
if (value !== null && typeof value === "object") continue;
|
||||
// 제외 규칙 6: 빈 값
|
||||
if (value === undefined || value === "" || value === null) continue;
|
||||
// 제외 규칙 7: 이미 commonFields에 있는 필드 (범용 폼 모달에서 가져온 필드)
|
||||
if (fieldName in commonFields) continue;
|
||||
|
||||
for (const fieldName of masterDetailFields) {
|
||||
const value = context.formData[fieldName];
|
||||
if (value !== undefined && value !== "" && value !== null && !(fieldName in commonFields)) {
|
||||
commonFields[fieldName] = value;
|
||||
}
|
||||
// 위 규칙에 해당하지 않는 단순 값(문자열, 숫자, 날짜 등)은 공통 필드로 전달
|
||||
commonFields[fieldName] = value;
|
||||
}
|
||||
console.log("📋 [handleSave] 최종 공통 필드 (마스터-디테일 필드 포함):", commonFields);
|
||||
console.log("📋 [handleSave] 최종 공통 필드 (규칙 기반 자동 추출):", commonFields);
|
||||
|
||||
for (const item of parsedData) {
|
||||
// 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리)
|
||||
|
|
@ -1074,6 +1063,13 @@ export class ButtonActionExecutor {
|
|||
dataWithMeta,
|
||||
);
|
||||
console.log("✅ [handleSave] RepeaterFieldGroup INSERT 완료:", insertResult.data);
|
||||
// 무시된 컬럼이 있으면 경고 출력
|
||||
if (insertResult.data?.data?.skippedColumns?.length > 0) {
|
||||
console.warn(
|
||||
`⚠️ [${repeaterTargetTable}] 테이블에 존재하지 않는 컬럼이 무시됨:`,
|
||||
insertResult.data.data.skippedColumns,
|
||||
);
|
||||
}
|
||||
} else if (item.id) {
|
||||
// UPDATE (기존 항목)
|
||||
const originalData = { id: item.id };
|
||||
|
|
@ -1089,10 +1085,15 @@ export class ButtonActionExecutor {
|
|||
console.log("✅ [handleSave] RepeaterFieldGroup UPDATE 완료:", updateResult.data);
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: unknown }; message?: string };
|
||||
const error = err as { response?: { data?: unknown; status?: number }; message?: string };
|
||||
console.error(
|
||||
`❌ [handleSave] RepeaterFieldGroup 저장 실패 (${repeaterTargetTable}):`,
|
||||
error.response?.data || error.message,
|
||||
{
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
message: error.message,
|
||||
fullError: JSON.stringify(error.response?.data, null, 2),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2112,7 +2113,20 @@ export class ButtonActionExecutor {
|
|||
|
||||
// 모든 그룹의 카티션 곱 생성
|
||||
const entryArrays = groupArrays.map((g) => g.entries);
|
||||
const combinations = cartesianProduct(entryArrays);
|
||||
|
||||
// 🆕 모든 그룹이 비어있는지 확인
|
||||
const allGroupsEmpty = entryArrays.every((arr) => arr.length === 0);
|
||||
|
||||
let combinations: any[][];
|
||||
if (allGroupsEmpty) {
|
||||
// 🆕 모든 그룹이 비어있으면 빈 조합 하나 생성 (품목 기본 정보만으로 저장)
|
||||
console.log("📝 [handleBatchSave] 모든 그룹이 비어있음 - 기본 레코드 생성");
|
||||
combinations = [[]];
|
||||
} else {
|
||||
// 빈 그룹을 필터링하여 카티션 곱 계산 (빈 그룹은 무시)
|
||||
const nonEmptyArrays = entryArrays.filter((arr) => arr.length > 0);
|
||||
combinations = nonEmptyArrays.length > 0 ? cartesianProduct(nonEmptyArrays) : [[]];
|
||||
}
|
||||
|
||||
// 각 조합을 개별 레코드로 저장
|
||||
for (let i = 0; i < combinations.length; i++) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue