feature/screen-management #278

Merged
kjs merged 6 commits from feature/screen-management into main 2025-12-11 14:32:35 +09:00
28 changed files with 4433 additions and 384 deletions
Showing only changes of commit f2b0ac8fd5 - Show all commits

View File

@ -341,6 +341,64 @@ export const uploadFiles = async (
});
}
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
const isRecordMode = req.body.isRecordMode === "true" || req.body.isRecordMode === true;
// 🔍 디버깅: 레코드 모드 조건 확인
console.log("🔍 [파일 업로드] 레코드 모드 조건 확인:", {
isRecordMode,
linkedTable,
recordId,
columnName,
finalTargetObjid,
"req.body.isRecordMode": req.body.isRecordMode,
"req.body.linkedTable": req.body.linkedTable,
"req.body.recordId": req.body.recordId,
"req.body.columnName": req.body.columnName,
});
if (isRecordMode && linkedTable && recordId && columnName) {
try {
// 해당 레코드의 모든 첨부파일 조회
const allFiles = await query<any>(
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate
FROM attach_file_info
WHERE target_objid = $1 AND status = 'ACTIVE'
ORDER BY regdate DESC`,
[finalTargetObjid]
);
// attachments JSONB 형태로 변환
const attachmentsJson = allFiles.map((f: any) => ({
objid: f.objid.toString(),
realFileName: f.real_file_name,
fileSize: Number(f.file_size),
fileExt: f.file_ext,
filePath: f.file_path,
regdate: f.regdate?.toISOString(),
}));
// 해당 테이블의 attachments 컬럼 업데이트
// 🔒 멀티테넌시: company_code 필터 추가
await query(
`UPDATE ${linkedTable}
SET ${columnName} = $1::jsonb, updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[JSON.stringify(attachmentsJson), recordId, companyCode]
);
console.log("📎 [레코드 모드] attachments 컬럼 자동 업데이트:", {
tableName: linkedTable,
recordId: recordId,
columnName: columnName,
fileCount: attachmentsJson.length,
});
} catch (updateError) {
// attachments 컬럼 업데이트 실패해도 파일 업로드는 성공으로 처리
console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError);
}
}
res.json({
success: true,
message: `${files.length}개 파일 업로드 완료`,
@ -405,6 +463,56 @@ export const deleteFile = async (
["DELETED", parseInt(objid)]
);
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
const targetObjid = fileRecord.target_objid;
if (targetObjid && !targetObjid.startsWith('screen_files:') && !targetObjid.startsWith('temp_')) {
// targetObjid 파싱: tableName:recordId:columnName 형식
const parts = targetObjid.split(':');
if (parts.length >= 3) {
const [tableName, recordId, columnName] = parts;
try {
// 해당 레코드의 남은 첨부파일 조회
const remainingFiles = await query<any>(
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate
FROM attach_file_info
WHERE target_objid = $1 AND status = 'ACTIVE'
ORDER BY regdate DESC`,
[targetObjid]
);
// attachments JSONB 형태로 변환
const attachmentsJson = remainingFiles.map((f: any) => ({
objid: f.objid.toString(),
realFileName: f.real_file_name,
fileSize: Number(f.file_size),
fileExt: f.file_ext,
filePath: f.file_path,
regdate: f.regdate?.toISOString(),
}));
// 해당 테이블의 attachments 컬럼 업데이트
// 🔒 멀티테넌시: company_code 필터 추가
await query(
`UPDATE ${tableName}
SET ${columnName} = $1::jsonb, updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[JSON.stringify(attachmentsJson), recordId, fileRecord.company_code]
);
console.log("📎 [파일 삭제] attachments 컬럼 자동 업데이트:", {
tableName,
recordId,
columnName,
remainingFiles: attachmentsJson.length,
});
} catch (updateError) {
// attachments 컬럼 업데이트 실패해도 파일 삭제는 성공으로 처리
console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError);
}
}
}
res.json({
success: true,
message: "파일이 삭제되었습니다.",

View File

@ -2141,3 +2141,4 @@ export async function multiTableSave(
client.release();
}
}

View File

@ -19,15 +19,21 @@ export class AdminService {
// menuType에 따른 WHERE 조건 생성
const menuTypeCondition =
menuType !== undefined ? `MENU.MENU_TYPE = ${parseInt(menuType)}` : "1 = 1";
menuType !== undefined
? `MENU.MENU_TYPE = ${parseInt(menuType)}`
: "1 = 1";
// 메뉴 관리 화면인지 좌측 사이드바인지 구분
// includeInactive가 true거나 menuType이 undefined면 메뉴 관리 화면
const includeInactive = paramMap.includeInactive === true;
const isManagementScreen = includeInactive || menuType === undefined;
// 메뉴 관리 화면: 모든 상태의 메뉴 표시, 좌측 사이드바: active만 표시
const statusCondition = isManagementScreen ? "1 = 1" : "MENU.STATUS = 'active'";
const subStatusCondition = isManagementScreen ? "1 = 1" : "MENU_SUB.STATUS = 'active'";
const statusCondition = isManagementScreen
? "1 = 1"
: "MENU.STATUS = 'active'";
const subStatusCondition = isManagementScreen
? "1 = 1"
: "MENU_SUB.STATUS = 'active'";
// 1. 권한 그룹 기반 필터링 (좌측 사이드바인 경우만)
let authFilter = "";
@ -35,7 +41,11 @@ export class AdminService {
let queryParams: any[] = [userLang];
let paramIndex = 2;
if (menuType !== undefined && userType !== "SUPER_ADMIN" && !isManagementScreen) {
if (
menuType !== undefined &&
userType !== "SUPER_ADMIN" &&
!isManagementScreen
) {
// 좌측 사이드바 + SUPER_ADMIN이 아닌 경우만 권한 체크
const userRoleGroups = await query<any>(
`
@ -56,45 +66,45 @@ export class AdminService {
);
if (userType === "COMPANY_ADMIN") {
// 회사 관리자: 자기 회사 메뉴는 모두, 공통 메뉴는 권한 있는 것만
// 회사 관리자: 권한 그룹 기반 필터링 적용
if (userRoleGroups.length > 0) {
const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid);
// 루트 메뉴: 회사 코드만 체크 (권한 체크 X)
authFilter = `AND MENU.COMPANY_CODE IN ($${paramIndex}, '*')`;
// 회사 관리자도 권한 그룹 설정에 따라 메뉴 필터링
authFilter = `
AND MENU.COMPANY_CODE IN ($${paramIndex}, '*')
AND EXISTS (
SELECT 1
FROM rel_menu_auth rma
WHERE rma.menu_objid = MENU.OBJID
AND rma.auth_objid = ANY($${paramIndex + 1})
AND rma.read_yn = 'Y'
)
`;
queryParams.push(userCompanyCode);
const companyParamIndex = paramIndex;
paramIndex++;
// 하위 메뉴: 회사 메뉴는 모두, 공통 메뉴는 권한 체크
// 하위 메뉴 권한 체크
unionFilter = `
AND (
MENU_SUB.COMPANY_CODE = $${companyParamIndex}
OR (
MENU_SUB.COMPANY_CODE = '*'
AND EXISTS (
SELECT 1
FROM rel_menu_auth rma
WHERE rma.menu_objid = MENU_SUB.OBJID
AND rma.auth_objid = ANY($${paramIndex})
AND rma.read_yn = 'Y'
)
)
AND MENU_SUB.COMPANY_CODE IN ($${paramIndex - 1}, '*')
AND EXISTS (
SELECT 1
FROM rel_menu_auth rma
WHERE rma.menu_objid = MENU_SUB.OBJID
AND rma.auth_objid = ANY($${paramIndex})
AND rma.read_yn = 'Y'
)
`;
queryParams.push(roleObjids);
paramIndex++;
logger.info(
`✅ 회사 관리자: 회사 ${userCompanyCode} 메뉴 전체 + 권한 있는 공통 메뉴`
`✅ 회사 관리자: 권한 있는 메뉴만 (${roleObjids.length}개 그룹)`
);
} else {
// 권한 그룹이 없는 회사 관리자: 자기 회사 메뉴만
authFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex}`;
queryParams.push(userCompanyCode);
paramIndex++;
logger.info(
`✅ 회사 관리자 (권한 그룹 없음): 회사 ${userCompanyCode} 메뉴만`
// 권한 그룹이 없는 회사 관리자: 메뉴 없음
logger.warn(
`⚠️ 회사 관리자 ${userId}는 권한 그룹이 없어 메뉴가 표시되지 않습니다.`
);
return [];
}
} else {
// 일반 사용자: 권한 그룹 필수
@ -131,7 +141,11 @@ export class AdminService {
return [];
}
}
} else if (menuType !== undefined && userType === "SUPER_ADMIN" && !isManagementScreen) {
} else if (
menuType !== undefined &&
userType === "SUPER_ADMIN" &&
!isManagementScreen
) {
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`);
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
@ -167,7 +181,7 @@ export class AdminService {
companyFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
queryParams.push(userCompanyCode);
paramIndex++;
// 하위 메뉴에도 회사 필터링 적용 (공통 메뉴 제외)
if (unionFilter === "") {
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex - 1}`;

View File

@ -903,6 +903,9 @@ export class DynamicFormService {
return `${key} = $${index + 1}::numeric`;
} else if (dataType === "boolean") {
return `${key} = $${index + 1}::boolean`;
} else if (dataType === 'jsonb' || dataType === 'json') {
// 🆕 JSONB/JSON 타입은 명시적 캐스팅
return `${key} = $${index + 1}::jsonb`;
} else {
// 문자열 타입은 캐스팅 불필요
return `${key} = $${index + 1}`;
@ -910,7 +913,17 @@ export class DynamicFormService {
})
.join(", ");
const values: any[] = Object.values(changedFields);
// 🆕 JSONB 타입 값은 JSON 문자열로 변환
const values: any[] = Object.keys(changedFields).map((key) => {
const value = changedFields[key];
const dataType = columnTypes[key];
// JSONB/JSON 타입이고 배열/객체인 경우 JSON 문자열로 변환
if ((dataType === 'jsonb' || dataType === 'json') && (Array.isArray(value) || (typeof value === 'object' && value !== null))) {
return JSON.stringify(value);
}
return value;
});
values.push(id); // WHERE 조건용 ID 추가
// 🔑 Primary Key 타입에 맞게 캐스팅

View File

@ -607,7 +607,9 @@ class NumberingRuleService {
}
const result = await pool.query(query, params);
if (result.rowCount === 0) return null;
if (result.rowCount === 0) {
return null;
}
const rule = result.rows[0];

View File

@ -2360,30 +2360,33 @@ export class ScreenManagementService {
const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0);
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
// 현재 최대 번호 조회
const existingScreens = await client.query<{ screen_code: string }>(
`SELECT screen_code FROM screen_definitions
WHERE company_code = $1 AND screen_code LIKE $2
ORDER BY screen_code DESC
LIMIT 10`,
[companyCode, `${companyCode}%`]
// 현재 최대 번호 조회 (숫자 추출 후 정렬)
// 패턴: COMPANY_CODE_XXX 또는 COMPANY_CODEXXX
const existingScreens = await client.query<{ screen_code: string; num: number }>(
`SELECT screen_code,
COALESCE(
NULLIF(
regexp_replace(screen_code, $2, '\\1'),
screen_code
)::integer,
0
) as num
FROM screen_definitions
WHERE company_code = $1
AND screen_code ~ $2
AND deleted_date IS NULL
ORDER BY num DESC
LIMIT 1`,
[companyCode, `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[_]?(\\d+)$`]
);
let maxNumber = 0;
const pattern = new RegExp(
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$`
);
for (const screen of existingScreens.rows) {
const match = screen.screen_code.match(pattern);
if (match) {
const number = parseInt(match[1], 10);
if (number > maxNumber) {
maxNumber = number;
}
}
if (existingScreens.rows.length > 0 && existingScreens.rows[0].num) {
maxNumber = existingScreens.rows[0].num;
}
console.log(`🔢 현재 최대 화면 코드 번호: ${companyCode}${maxNumber}`);
// count개의 코드를 순차적으로 생성
const codes: string[] = [];
for (let i = 0; i < count; i++) {

View File

@ -9,6 +9,7 @@ import { useRouter } from "next/navigation";
import { AlertCircle } from "lucide-react";
import { DualListBox } from "@/components/common/DualListBox";
import { MenuPermissionsTable } from "./MenuPermissionsTable";
import { useMenu } from "@/contexts/MenuContext";
interface RoleDetailManagementProps {
roleId: string;
@ -25,6 +26,7 @@ interface RoleDetailManagementProps {
export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
const { user: currentUser } = useAuth();
const router = useRouter();
const { refreshMenus } = useMenu();
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
@ -178,6 +180,9 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
if (response.success) {
alert("멤버가 성공적으로 저장되었습니다.");
loadMembers(); // 새로고침
// 사이드바 메뉴 새로고침 (현재 사용자가 영향받을 수 있음)
await refreshMenus();
} else {
alert(response.message || "멤버 저장에 실패했습니다.");
}
@ -187,7 +192,7 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
} finally {
setIsSavingMembers(false);
}
}, [roleGroup, selectedUsers, loadMembers]);
}, [roleGroup, selectedUsers, loadMembers, refreshMenus]);
// 메뉴 권한 저장 핸들러
const handleSavePermissions = useCallback(async () => {
@ -200,6 +205,9 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
if (response.success) {
alert("메뉴 권한이 성공적으로 저장되었습니다.");
loadMenuPermissions(); // 새로고침
// 사이드바 메뉴 새로고침 (권한 변경 즉시 반영)
await refreshMenus();
} else {
alert(response.message || "메뉴 권한 저장에 실패했습니다.");
}
@ -209,7 +217,7 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
} finally {
setIsSavingPermissions(false);
}
}, [roleGroup, menuPermissions, loadMenuPermissions]);
}, [roleGroup, menuPermissions, loadMenuPermissions, refreshMenus]);
if (isLoading) {
return (

View File

@ -166,18 +166,28 @@ export default function CopyScreenModal({
// linkedScreens 로딩이 완료되면 화면 코드 생성
useEffect(() => {
// 모달 화면들의 코드가 모두 설정되었는지 확인
const allModalCodesSet = linkedScreens.length === 0 ||
linkedScreens.every(screen => screen.newScreenCode);
console.log("🔍 코드 생성 조건 체크:", {
targetCompanyCode,
loadingLinkedScreens,
screenCode,
linkedScreensCount: linkedScreens.length,
allModalCodesSet,
});
if (targetCompanyCode && !loadingLinkedScreens && !screenCode) {
// 조건: 회사 코드가 있고, 로딩이 완료되고, (메인 코드가 없거나 모달 코드가 없을 때)
const needsCodeGeneration = targetCompanyCode &&
!loadingLinkedScreens &&
(!screenCode || (linkedScreens.length > 0 && !allModalCodesSet));
if (needsCodeGeneration) {
console.log("✅ 화면 코드 생성 시작 (linkedScreens 개수:", linkedScreens.length, ")");
generateScreenCodes();
}
}, [targetCompanyCode, loadingLinkedScreens, screenCode]);
}, [targetCompanyCode, loadingLinkedScreens, screenCode, linkedScreens]);
// 회사 목록 조회
const loadCompanies = async () => {

View File

@ -678,12 +678,13 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
// 실제 모달 크기 = 컨텐츠 + 헤더 + gap + padding
// 실제 모달 크기 = 컨텐츠 + 헤더 + gap + padding + 라벨 공간
const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3)
const dialogGap = 16; // DialogContent gap-4
const extraPadding = 24; // 추가 여백 (안전 마진)
const labelSpace = 30; // 입력 필드 위 라벨 공간 (-top-6 = 24px + 여유)
const totalHeight = screenDimensions.height + headerHeight + dialogGap + extraPadding;
const totalHeight = screenDimensions.height + headerHeight + dialogGap + extraPadding + labelSpace;
return {
className: "overflow-hidden p-0",
@ -729,7 +730,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
className="relative bg-white"
style={{
width: screenDimensions?.width || 800,
height: screenDimensions?.height || 600,
height: (screenDimensions?.height || 600) + 30, // 라벨 공간 추가
transformOrigin: "center center",
maxWidth: "100%",
maxHeight: "100%",
@ -739,13 +740,14 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 컴포넌트 위치를 offset만큼 조정
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
const labelSpace = 30; // 라벨 공간 (입력 필드 위 -top-6 라벨용)
const adjustedComponent = {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace, // 라벨 공간 추가
},
};
@ -759,12 +761,27 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
});
}
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
const enrichedFormData = {
...(groupData.length > 0 ? groupData[0] : formData),
tableName: screenData.screenInfo?.tableName, // 테이블명 추가
screenId: modalState.screenId, // 화면 ID 추가
};
// 🔍 디버깅: enrichedFormData 확인
console.log("🔑 [EditModal] enrichedFormData 생성:", {
"screenData.screenInfo": screenData.screenInfo,
"screenData.screenInfo?.tableName": screenData.screenInfo?.tableName,
"enrichedFormData.tableName": enrichedFormData.tableName,
"enrichedFormData.id": enrichedFormData.id,
});
return (
<InteractiveScreenViewerDynamic
key={component.id}
component={adjustedComponent}
allComponents={screenData.components}
formData={groupData.length > 0 ? groupData[0] : formData}
formData={enrichedFormData}
onFormDataChange={(fieldName, value) => {
// 🆕 그룹 데이터가 있으면 처리
if (groupData.length > 0) {

View File

@ -317,6 +317,11 @@ apiClient.interceptors.response.use(
return Promise.reject(error);
}
// 채번 규칙 미리보기 API 실패는 조용하게 처리 (화면 로드 시 자주 발생)
if (url?.includes("/numbering-rules/") && url?.includes("/preview")) {
return Promise.reject(error);
}
// 다른 에러들은 기존처럼 상세 로그 출력
console.error("API 응답 오류:", {
status: status,
@ -324,7 +329,6 @@ apiClient.interceptors.response.use(
url: url,
data: error.response?.data,
message: error.message,
headers: error.config?.headers,
});
// 401 에러 처리

View File

@ -109,11 +109,24 @@ export async function deleteNumberingRule(ruleId: string): Promise<ApiResponse<v
export async function previewNumberingCode(
ruleId: string
): Promise<ApiResponse<{ generatedCode: string }>> {
// ruleId 유효성 검사
if (!ruleId || ruleId === "undefined" || ruleId === "null") {
return { success: false, error: "채번 규칙 ID가 설정되지 않았습니다" };
}
try {
const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`);
if (!response.data) {
return { success: false, error: "서버 응답이 비어있습니다" };
}
return response.data;
} catch (error: any) {
return { success: false, error: error.message || "코드 미리보기 실패" };
const errorMessage =
error.response?.data?.error ||
error.response?.data?.message ||
error.message ||
"코드 미리보기 실패";
return { success: false, error: errorMessage };
}
}

View File

@ -10,6 +10,7 @@ import { apiClient } from "@/lib/api/client";
import { FileViewerModal } from "./FileViewerModal";
import { FileManagerModal } from "./FileManagerModal";
import { FileUploadConfig, FileInfo, FileUploadStatus, FileUploadResponse } from "./types";
import { useAuth } from "@/hooks/useAuth";
import {
Upload,
File,
@ -92,6 +93,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
onDragEnd,
onUpdate,
}) => {
// 🔑 인증 정보 가져오기
const { user } = useAuth();
const [uploadedFiles, setUploadedFiles] = useState<FileInfo[]>([]);
const [uploadStatus, setUploadStatus] = useState<FileUploadStatus>("idle");
const [dragOver, setDragOver] = useState(false);
@ -102,28 +106,94 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
const recordTableName = formData?.tableName || component.tableName;
const recordId = formData?.id;
// 🔑 컬럼명 결정: 레코드 모드에서는 무조건 'attachments' 사용
// component.columnName이나 component.id는 '파일_업로드' 같은 한글 라벨일 수 있어서 DB 컬럼명으로 부적합
// 레코드 모드가 아닐 때만 component.columnName 또는 component.id 사용
const columnName = isRecordMode ? 'attachments' : (component.columnName || component.id || 'attachments');
// 🔑 레코드 모드용 targetObjid 생성
const getRecordTargetObjid = useCallback(() => {
if (isRecordMode && recordTableName && recordId) {
return `${recordTableName}:${recordId}:${columnName}`;
}
return null;
}, [isRecordMode, recordTableName, recordId, columnName]);
// 🔑 레코드별 고유 키 생성 (localStorage, 전역 상태용)
const getUniqueKey = useCallback(() => {
if (isRecordMode && recordTableName && recordId) {
// 레코드 모드: 테이블명:레코드ID:컴포넌트ID 형태로 고유 키 생성
return `fileUpload_${recordTableName}_${recordId}_${component.id}`;
}
// 기본 모드: 컴포넌트 ID만 사용
return `fileUpload_${component.id}`;
}, [isRecordMode, recordTableName, recordId, component.id]);
// 🔍 디버깅: 레코드 모드 상태 로깅
useEffect(() => {
console.log("📎 [FileUploadComponent] 모드 확인:", {
isRecordMode,
recordTableName,
recordId,
columnName,
targetObjid: getRecordTargetObjid(),
uniqueKey: getUniqueKey(),
formDataKeys: formData ? Object.keys(formData) : [],
// 🔍 추가 디버깅: 어디서 tableName이 오는지 확인
"formData.tableName": formData?.tableName,
"component.tableName": component.tableName,
"component.columnName": component.columnName,
"component.id": component.id,
});
}, [isRecordMode, recordTableName, recordId, columnName, getRecordTargetObjid, getUniqueKey, formData, component.tableName, component.columnName, component.id]);
// 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드
const prevRecordIdRef = useRef<any>(null);
useEffect(() => {
if (prevRecordIdRef.current !== recordId) {
console.log("📎 [FileUploadComponent] 레코드 ID 변경 감지:", {
prev: prevRecordIdRef.current,
current: recordId,
isRecordMode,
});
prevRecordIdRef.current = recordId;
// 레코드 모드에서 레코드 ID가 변경되면 파일 목록 초기화
if (isRecordMode) {
setUploadedFiles([]);
}
}
}, [recordId, isRecordMode]);
// 컴포넌트 마운트 시 즉시 localStorage에서 파일 복원
useEffect(() => {
if (!component?.id) return;
try {
const backupKey = `fileUpload_${component.id}`;
// 🔑 레코드별 고유 키 사용
const backupKey = getUniqueKey();
const backupFiles = localStorage.getItem(backupKey);
if (backupFiles) {
const parsedFiles = JSON.parse(backupFiles);
if (parsedFiles.length > 0) {
console.log("🚀 컴포넌트 마운트 시 파일 즉시 복원:", {
uniqueKey: backupKey,
componentId: component.id,
recordId: recordId,
restoredFiles: parsedFiles.length,
files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
});
setUploadedFiles(parsedFiles);
// 전역 상태에도 복원
// 전역 상태에도 복원 (레코드별 고유 키 사용)
if (typeof window !== "undefined") {
(window as any).globalFileState = {
...(window as any).globalFileState,
[component.id]: parsedFiles,
[backupKey]: parsedFiles,
};
}
}
@ -131,7 +201,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
} catch (e) {
console.warn("컴포넌트 마운트 시 파일 복원 실패:", e);
}
}, [component.id]); // component.id가 변경될 때만 실행
}, [component.id, getUniqueKey, recordId]); // 레코드별 고유 키 변경 시 재실행
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
useEffect(() => {
@ -152,12 +222,14 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const newFiles = event.detail.files || [];
setUploadedFiles(newFiles);
// localStorage 백업 업데이트
// localStorage 백업 업데이트 (레코드별 고유 키 사용)
try {
const backupKey = `fileUpload_${component.id}`;
const backupKey = getUniqueKey();
localStorage.setItem(backupKey, JSON.stringify(newFiles));
console.log("💾 화면설계 모드 동기화 후 localStorage 백업 업데이트:", {
uniqueKey: backupKey,
componentId: component.id,
recordId: recordId,
fileCount: newFiles.length,
});
} catch (e) {
@ -201,6 +273,16 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
if (!component?.id) return false;
try {
// 🔑 레코드 모드: 해당 행의 파일만 조회
if (isRecordMode && recordTableName && recordId) {
console.log("📂 [FileUploadComponent] 레코드 모드 파일 조회:", {
tableName: recordTableName,
recordId: recordId,
columnName: columnName,
targetObjid: getRecordTargetObjid(),
});
}
// 1. formData에서 screenId 가져오기
let screenId = formData?.screenId;
@ -232,11 +314,13 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const params = {
screenId,
componentId: component.id,
tableName: formData?.tableName || component.tableName,
recordId: formData?.id,
columnName: component.columnName || component.id, // 🔑 columnName이 없으면 component.id 사용
tableName: recordTableName || formData?.tableName || component.tableName,
recordId: recordId || formData?.id,
columnName: columnName, // 🔑 레코드 모드에서 사용하는 columnName
};
console.log("📂 [FileUploadComponent] 파일 조회 파라미터:", params);
const response = await getComponentFiles(params);
if (response.success) {
@ -255,11 +339,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}));
// 🔄 localStorage의 기존 파일과 서버 파일 병합
// 🔄 localStorage의 기존 파일과 서버 파일 병합 (레코드별 고유 키 사용)
let finalFiles = formattedFiles;
const uniqueKey = getUniqueKey();
try {
const backupKey = `fileUpload_${component.id}`;
const backupFiles = localStorage.getItem(backupKey);
const backupFiles = localStorage.getItem(uniqueKey);
if (backupFiles) {
const parsedBackupFiles = JSON.parse(backupFiles);
@ -268,7 +352,12 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const additionalFiles = parsedBackupFiles.filter((f: any) => !serverObjIds.has(f.objid));
finalFiles = [...formattedFiles, ...additionalFiles];
console.log("📂 [FileUploadComponent] 파일 병합 완료:", {
uniqueKey,
serverFiles: formattedFiles.length,
localFiles: parsedBackupFiles.length,
finalFiles: finalFiles.length,
});
}
} catch (e) {
console.warn("파일 병합 중 오류:", e);
@ -276,11 +365,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
setUploadedFiles(finalFiles);
// 전역 상태에도 저장
// 전역 상태에도 저장 (레코드별 고유 키 사용)
if (typeof window !== "undefined") {
(window as any).globalFileState = {
...(window as any).globalFileState,
[component.id]: finalFiles,
[uniqueKey]: finalFiles,
};
// 🌐 전역 파일 저장소에 등록 (페이지 간 공유용)
@ -288,12 +377,12 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
uploadPage: window.location.pathname,
componentId: component.id,
screenId: formData?.screenId,
recordId: recordId,
});
// localStorage 백업도 병합된 파일로 업데이트
// localStorage 백업도 병합된 파일로 업데이트 (레코드별 고유 키 사용)
try {
const backupKey = `fileUpload_${component.id}`;
localStorage.setItem(backupKey, JSON.stringify(finalFiles));
localStorage.setItem(uniqueKey, JSON.stringify(finalFiles));
} catch (e) {
console.warn("localStorage 백업 업데이트 실패:", e);
}
@ -304,7 +393,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
console.error("파일 조회 오류:", error);
}
return false; // 기존 로직 사용
}, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id]);
}, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id, getUniqueKey, recordId, isRecordMode, recordTableName, columnName]);
// 컴포넌트 파일 동기화 (DB 우선, localStorage는 보조)
useEffect(() => {
@ -316,6 +405,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
componentFiles: componentFiles.length,
formData: formData,
screenId: formData?.screenId,
tableName: formData?.tableName, // 🔍 테이블명 확인
recordId: formData?.id, // 🔍 레코드 ID 확인
currentUploadedFiles: uploadedFiles.length,
});
@ -371,9 +462,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
setUploadedFiles(files);
setForceUpdate((prev) => prev + 1);
// localStorage 백업도 업데이트
// localStorage 백업도 업데이트 (레코드별 고유 키 사용)
try {
const backupKey = `fileUpload_${component.id}`;
const backupKey = getUniqueKey();
localStorage.setItem(backupKey, JSON.stringify(files));
} catch (e) {
console.warn("localStorage 백업 실패:", e);
@ -462,10 +553,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
toast.loading("파일을 업로드하는 중...", { id: "file-upload" });
try {
// targetObjid 생성 - 템플릿 vs 데이터 파일 구분
const tableName = formData?.tableName || component.tableName || "default_table";
const recordId = formData?.id;
const columnName = component.columnName || component.id;
// 🔑 레코드 모드 우선 사용
const effectiveTableName = recordTableName || formData?.tableName || component.tableName || "default_table";
const effectiveRecordId = recordId || formData?.id;
const effectiveColumnName = columnName;
// screenId 추출 (우선순위: formData > URL)
let screenId = formData?.screenId;
@ -478,39 +569,56 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}
let targetObjid;
// 우선순위: 1) 실제 데이터 (recordId가 숫자/문자열이고 temp_가 아님) > 2) 템플릿 (screenId) > 3) 기본값
const isRealRecord = recordId && typeof recordId !== 'undefined' && !String(recordId).startsWith('temp_');
// 🔑 레코드 모드 판단 개선
const effectiveIsRecordMode = isRecordMode || (effectiveRecordId && !String(effectiveRecordId).startsWith('temp_'));
if (isRealRecord && tableName) {
// 실제 데이터 파일 (진짜 레코드 ID가 있을 때만)
targetObjid = `${tableName}:${recordId}:${columnName}`;
console.log("📁 실제 데이터 파일 업로드:", targetObjid);
if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) {
// 🎯 레코드 모드: 특정 행에 파일 연결
targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`;
console.log("📁 [레코드 모드] 파일 업로드:", {
targetObjid,
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
});
} else if (screenId) {
// 🔑 템플릿 파일 (백엔드 조회 형식과 동일하게)
targetObjid = `screen_files:${screenId}:${component.id}:${columnName}`;
targetObjid = `screen_files:${screenId}:${component.id}:${effectiveColumnName}`;
console.log("📝 [템플릿 모드] 파일 업로드:", targetObjid);
} else {
// 기본값 (화면관리에서 사용)
targetObjid = `temp_${component.id}`;
console.log("📝 기본 파일 업로드:", targetObjid);
console.log("📝 [기본 모드] 파일 업로드:", targetObjid);
}
// 🔒 현재 사용자의 회사 코드 가져오기 (멀티테넌시 격리)
const userCompanyCode = (window as any).__user__?.companyCode;
const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode;
console.log("📤 [FileUploadComponent] 파일 업로드 준비:", {
userCompanyCode,
isRecordMode: effectiveIsRecordMode,
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
targetObjid,
});
const uploadData = {
// 🎯 formData에서 백엔드 API 설정 가져오기
autoLink: formData?.autoLink || true,
linkedTable: formData?.linkedTable || tableName,
recordId: formData?.recordId || recordId || `temp_${component.id}`,
columnName: formData?.columnName || columnName,
linkedTable: formData?.linkedTable || effectiveTableName,
recordId: effectiveRecordId || `temp_${component.id}`,
columnName: effectiveColumnName,
isVirtualFileColumn: formData?.isVirtualFileColumn || true,
docType: component.fileConfig?.docType || "DOCUMENT",
docTypeName: component.fileConfig?.docTypeName || "일반 문서",
companyCode: userCompanyCode, // 🔒 멀티테넌시: 회사 코드 명시적 전달
// 호환성을 위한 기존 필드들
tableName: tableName,
fieldName: columnName,
tableName: effectiveTableName,
fieldName: effectiveColumnName,
targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가
// 🆕 레코드 모드 플래그
isRecordMode: effectiveIsRecordMode,
};
@ -553,9 +661,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
setUploadedFiles(updatedFiles);
setUploadStatus("success");
// localStorage 백업
// localStorage 백업 (레코드별 고유 키 사용)
try {
const backupKey = `fileUpload_${component.id}`;
const backupKey = getUniqueKey();
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
} catch (e) {
console.warn("localStorage 백업 실패:", e);
@ -563,9 +671,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
if (typeof window !== "undefined") {
// 전역 파일 상태 업데이트
// 전역 파일 상태 업데이트 (레코드별 고유 키 사용)
const globalFileState = (window as any).globalFileState || {};
globalFileState[component.id] = updatedFiles;
const uniqueKey = getUniqueKey();
globalFileState[uniqueKey] = updatedFiles;
(window as any).globalFileState = globalFileState;
// 🌐 전역 파일 저장소에 새 파일 등록 (페이지 간 공유용)
@ -573,12 +682,15 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
uploadPage: window.location.pathname,
componentId: component.id,
screenId: formData?.screenId,
recordId: recordId, // 🆕 레코드 ID 추가
});
// 모든 파일 컴포넌트에 동기화 이벤트 발생
const syncEvent = new CustomEvent("globalFileStateChanged", {
detail: {
componentId: component.id,
uniqueKey: uniqueKey, // 🆕 고유 키 추가
recordId: recordId, // 🆕 레코드 ID 추가
files: updatedFiles,
fileCount: updatedFiles.length,
timestamp: Date.now(),
@ -612,22 +724,54 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
console.warn("⚠️ onUpdate 콜백이 없습니다!");
}
// 🆕 레코드 모드: attachments 컬럼 동기화 (formData 업데이트)
if (effectiveIsRecordMode && onFormDataChange) {
// 파일 정보를 간소화하여 attachments 컬럼에 저장할 형태로 변환
const attachmentsData = updatedFiles.map(file => ({
objid: file.objid,
realFileName: file.realFileName,
fileSize: file.fileSize,
fileExt: file.fileExt,
filePath: file.filePath,
regdate: file.regdate || new Date().toISOString(),
}));
console.log("📎 [레코드 모드] attachments 컬럼 동기화:", {
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
fileCount: attachmentsData.length,
});
// onFormDataChange를 통해 부모 컴포넌트에 attachments 업데이트 알림
onFormDataChange({
[effectiveColumnName]: attachmentsData,
// 🆕 백엔드에서 attachments 컬럼 업데이트를 위한 메타 정보
__attachmentsUpdate: {
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
files: attachmentsData,
}
});
}
// 그리드 파일 상태 새로고침 이벤트 발생
if (typeof window !== "undefined") {
const refreshEvent = new CustomEvent("refreshFileStatus", {
detail: {
tableName: tableName,
recordId: recordId,
columnName: columnName,
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
targetObjid: targetObjid,
fileCount: updatedFiles.length,
},
});
window.dispatchEvent(refreshEvent);
console.log("🔄 그리드 파일 상태 새로고침 이벤트 발생:", {
tableName,
recordId,
columnName,
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
targetObjid,
fileCount: updatedFiles.length,
});
@ -705,9 +849,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const updatedFiles = uploadedFiles.filter((f) => f.objid !== fileId);
setUploadedFiles(updatedFiles);
// localStorage 백업 업데이트
// localStorage 백업 업데이트 (레코드별 고유 키 사용)
try {
const backupKey = `fileUpload_${component.id}`;
const backupKey = getUniqueKey();
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
} catch (e) {
console.warn("localStorage 백업 업데이트 실패:", e);
@ -715,15 +859,18 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
if (typeof window !== "undefined") {
// 전역 파일 상태 업데이트
// 전역 파일 상태 업데이트 (레코드별 고유 키 사용)
const globalFileState = (window as any).globalFileState || {};
globalFileState[component.id] = updatedFiles;
const uniqueKey = getUniqueKey();
globalFileState[uniqueKey] = updatedFiles;
(window as any).globalFileState = globalFileState;
// 모든 파일 컴포넌트에 동기화 이벤트 발생
const syncEvent = new CustomEvent("globalFileStateChanged", {
detail: {
componentId: component.id,
uniqueKey: uniqueKey, // 🆕 고유 키 추가
recordId: recordId, // 🆕 레코드 ID 추가
files: updatedFiles,
fileCount: updatedFiles.length,
timestamp: Date.now(),
@ -749,13 +896,42 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
});
}
// 🆕 레코드 모드: attachments 컬럼 동기화 (파일 삭제 후)
if (isRecordMode && onFormDataChange && recordTableName && recordId) {
const attachmentsData = updatedFiles.map(f => ({
objid: f.objid,
realFileName: f.realFileName,
fileSize: f.fileSize,
fileExt: f.fileExt,
filePath: f.filePath,
regdate: f.regdate || new Date().toISOString(),
}));
console.log("📎 [레코드 모드] 파일 삭제 후 attachments 동기화:", {
tableName: recordTableName,
recordId: recordId,
columnName: columnName,
remainingFiles: attachmentsData.length,
});
onFormDataChange({
[columnName]: attachmentsData,
__attachmentsUpdate: {
tableName: recordTableName,
recordId: recordId,
columnName: columnName,
files: attachmentsData,
}
});
}
toast.success(`${fileName} 삭제 완료`);
} catch (error) {
console.error("파일 삭제 오류:", error);
toast.error("파일 삭제에 실패했습니다.");
}
},
[uploadedFiles, onUpdate, component.id],
[uploadedFiles, onUpdate, component.id, isRecordMode, onFormDataChange, recordTableName, recordId, columnName, getUniqueKey],
);
// 대표 이미지 Blob URL 로드

View File

@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { ItemSelectionModal } from "./ItemSelectionModal";
import { RepeaterTable } from "./RepeaterTable";
import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition } from "./types";
import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition, DynamicDataSourceOption } from "./types";
import { useCalculation } from "./useCalculation";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@ -293,6 +293,9 @@ export function ModalRepeaterTableComponent({
// 🆕 수주일 일괄 적용 플래그 (딱 한 번만 실행)
const [isOrderDateApplied, setIsOrderDateApplied] = useState(false);
// 🆕 동적 데이터 소스 활성화 상태 (컬럼별로 현재 선택된 옵션 ID)
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
// columns가 비어있으면 sourceColumns로부터 자동 생성
const columns = React.useMemo((): RepeaterColumnConfig[] => {
@ -409,6 +412,193 @@ export function ModalRepeaterTableComponent({
}, [localValue, columnName, component?.id, onFormDataChange, targetTable]);
const { calculateRow, calculateAll } = useCalculation(calculationRules);
/**
*
*
*/
const handleDataSourceChange = async (columnField: string, optionId: string) => {
console.log(`🔄 데이터 소스 변경: ${columnField}${optionId}`);
// 활성화 상태 업데이트
setActiveDataSources((prev) => ({
...prev,
[columnField]: optionId,
}));
// 해당 컬럼 찾기
const column = columns.find((col) => col.field === columnField);
if (!column?.dynamicDataSource?.enabled) {
console.warn(`⚠️ 컬럼 "${columnField}"에 동적 데이터 소스가 설정되지 않음`);
return;
}
// 선택된 옵션 찾기
const option = column.dynamicDataSource.options.find((opt) => opt.id === optionId);
if (!option) {
console.warn(`⚠️ 옵션 "${optionId}"을 찾을 수 없음`);
return;
}
// 모든 행에 대해 새 값 조회
const updatedData = await Promise.all(
localValue.map(async (row, index) => {
try {
const newValue = await fetchDynamicValue(option, row);
console.log(` ✅ 행 ${index}: ${columnField} = ${newValue}`);
return {
...row,
[columnField]: newValue,
};
} catch (error) {
console.error(` ❌ 행 ${index} 조회 실패:`, error);
return row;
}
})
);
// 계산 필드 업데이트 후 데이터 반영
const calculatedData = calculateAll(updatedData);
handleChange(calculatedData);
};
/**
*
*/
async function fetchDynamicValue(
option: DynamicDataSourceOption,
rowData: any
): Promise<any> {
if (option.sourceType === "table" && option.tableConfig) {
// 테이블 직접 조회 (단순 조인)
const { tableName, valueColumn, joinConditions } = option.tableConfig;
const whereConditions: Record<string, any> = {};
for (const cond of joinConditions) {
const value = rowData[cond.sourceField];
if (value === undefined || value === null) {
console.warn(`⚠️ 조인 조건의 소스 필드 "${cond.sourceField}" 값이 없음`);
return undefined;
}
whereConditions[cond.targetField] = value;
}
console.log(`🔍 테이블 조회: ${tableName}`, whereConditions);
const response = await apiClient.post(
`/table-management/tables/${tableName}/data`,
{ search: whereConditions, size: 1, page: 1 }
);
if (response.data.success && response.data.data?.data?.length > 0) {
return response.data.data.data[0][valueColumn];
}
return undefined;
} else if (option.sourceType === "multiTable" && option.multiTableConfig) {
// 테이블 복합 조인 (2개 이상 테이블 순차 조인)
const { joinChain, valueColumn } = option.multiTableConfig;
if (!joinChain || joinChain.length === 0) {
console.warn("⚠️ 조인 체인이 비어있습니다.");
return undefined;
}
console.log(`🔗 복합 조인 시작: ${joinChain.length}단계`);
// 현재 값을 추적 (첫 단계는 현재 행에서 시작)
let currentValue: any = null;
let currentRow: any = null;
for (let i = 0; i < joinChain.length; i++) {
const step = joinChain[i];
const { tableName, joinCondition, outputField } = step;
// 조인 조건 값 가져오기
let fromValue: any;
if (i === 0) {
// 첫 번째 단계: 현재 행에서 값 가져오기
fromValue = rowData[joinCondition.fromField];
console.log(` 📍 단계 ${i + 1}: 현재행.${joinCondition.fromField} = ${fromValue}`);
} else {
// 이후 단계: 이전 조회 결과에서 값 가져오기
fromValue = currentRow?.[joinCondition.fromField] || currentValue;
console.log(` 📍 단계 ${i + 1}: 이전결과.${joinCondition.fromField} = ${fromValue}`);
}
if (fromValue === undefined || fromValue === null) {
console.warn(`⚠️ 단계 ${i + 1}: 조인 조건 값이 없습니다. (${joinCondition.fromField})`);
return undefined;
}
// 테이블 조회
const whereConditions: Record<string, any> = {
[joinCondition.toField]: fromValue
};
console.log(` 🔍 단계 ${i + 1}: ${tableName} 조회`, whereConditions);
try {
const response = await apiClient.post(
`/table-management/tables/${tableName}/data`,
{ search: whereConditions, size: 1, page: 1 }
);
if (response.data.success && response.data.data?.data?.length > 0) {
currentRow = response.data.data.data[0];
currentValue = outputField ? currentRow[outputField] : currentRow;
console.log(` ✅ 단계 ${i + 1} 성공:`, { outputField, value: currentValue });
} else {
console.warn(` ⚠️ 단계 ${i + 1}: 조회 결과 없음`);
return undefined;
}
} catch (error) {
console.error(` ❌ 단계 ${i + 1} 조회 실패:`, error);
return undefined;
}
}
// 최종 값 반환 (마지막 테이블에서 valueColumn 가져오기)
const finalValue = currentRow?.[valueColumn];
console.log(`🎯 복합 조인 완료: ${valueColumn} = ${finalValue}`);
return finalValue;
} else if (option.sourceType === "api" && option.apiConfig) {
// 전용 API 호출 (복잡한 다중 조인)
const { endpoint, method = "GET", parameterMappings, responseValueField } = option.apiConfig;
// 파라미터 빌드
const params: Record<string, any> = {};
for (const mapping of parameterMappings) {
const value = rowData[mapping.sourceField];
if (value !== undefined && value !== null) {
params[mapping.paramName] = value;
}
}
console.log(`🔍 API 호출: ${method} ${endpoint}`, params);
let response;
if (method === "POST") {
response = await apiClient.post(endpoint, params);
} else {
response = await apiClient.get(endpoint, { params });
}
if (response.data.success && response.data.data) {
// responseValueField로 값 추출 (중첩 경로 지원: "data.price")
const keys = responseValueField.split(".");
let value = response.data.data;
for (const key of keys) {
value = value?.[key];
}
return value;
}
return undefined;
}
return undefined;
}
// 초기 데이터에 계산 필드 적용
useEffect(() => {
@ -579,6 +769,8 @@ export function ModalRepeaterTableComponent({
onDataChange={handleChange}
onRowChange={handleRowChange}
onRowDelete={handleRowDelete}
activeDataSources={activeDataSources}
onDataSourceChange={handleDataSourceChange}
/>
{/* 항목 선택 모달 */}

View File

@ -9,7 +9,8 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
import { ModalRepeaterTableProps, RepeaterColumnConfig, ColumnMapping, CalculationRule } from "./types";
import { ModalRepeaterTableProps, RepeaterColumnConfig, ColumnMapping, CalculationRule, DynamicDataSourceConfig, DynamicDataSourceOption, MultiTableJoinStep } from "./types";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { cn } from "@/lib/utils";
@ -169,6 +170,10 @@ export function ModalRepeaterTableConfigPanel({
const [openTableCombo, setOpenTableCombo] = useState(false);
const [openTargetTableCombo, setOpenTargetTableCombo] = useState(false);
const [openUniqueFieldCombo, setOpenUniqueFieldCombo] = useState(false);
// 동적 데이터 소스 설정 모달
const [dynamicSourceModalOpen, setDynamicSourceModalOpen] = useState(false);
const [editingDynamicSourceColumnIndex, setEditingDynamicSourceColumnIndex] = useState<number | null>(null);
// config 변경 시 localConfig 동기화 (cleanupInitialConfig 적용)
useEffect(() => {
@ -397,6 +402,101 @@ export function ModalRepeaterTableConfigPanel({
updateConfig({ calculationRules: rules });
};
// 동적 데이터 소스 설정 함수들
const openDynamicSourceModal = (columnIndex: number) => {
setEditingDynamicSourceColumnIndex(columnIndex);
setDynamicSourceModalOpen(true);
};
const toggleDynamicDataSource = (columnIndex: number, enabled: boolean) => {
const columns = [...(localConfig.columns || [])];
if (enabled) {
columns[columnIndex] = {
...columns[columnIndex],
dynamicDataSource: {
enabled: true,
options: [],
},
};
} else {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { dynamicDataSource, ...rest } = columns[columnIndex];
columns[columnIndex] = rest;
}
updateConfig({ columns });
};
const addDynamicSourceOption = (columnIndex: number) => {
const columns = [...(localConfig.columns || [])];
const col = columns[columnIndex];
const newOption: DynamicDataSourceOption = {
id: `option_${Date.now()}`,
label: "새 옵션",
sourceType: "table",
tableConfig: {
tableName: "",
valueColumn: "",
joinConditions: [],
},
};
columns[columnIndex] = {
...col,
dynamicDataSource: {
...col.dynamicDataSource!,
enabled: true,
options: [...(col.dynamicDataSource?.options || []), newOption],
},
};
updateConfig({ columns });
};
const updateDynamicSourceOption = (columnIndex: number, optionIndex: number, updates: Partial<DynamicDataSourceOption>) => {
const columns = [...(localConfig.columns || [])];
const col = columns[columnIndex];
const options = [...(col.dynamicDataSource?.options || [])];
options[optionIndex] = { ...options[optionIndex], ...updates };
columns[columnIndex] = {
...col,
dynamicDataSource: {
...col.dynamicDataSource!,
options,
},
};
updateConfig({ columns });
};
const removeDynamicSourceOption = (columnIndex: number, optionIndex: number) => {
const columns = [...(localConfig.columns || [])];
const col = columns[columnIndex];
const options = [...(col.dynamicDataSource?.options || [])];
options.splice(optionIndex, 1);
columns[columnIndex] = {
...col,
dynamicDataSource: {
...col.dynamicDataSource!,
options,
},
};
updateConfig({ columns });
};
const setDefaultDynamicSourceOption = (columnIndex: number, optionId: string) => {
const columns = [...(localConfig.columns || [])];
const col = columns[columnIndex];
columns[columnIndex] = {
...col,
dynamicDataSource: {
...col.dynamicDataSource!,
defaultOptionId: optionId,
},
};
updateConfig({ columns });
};
return (
<div className="space-y-6 p-4">
{/* 소스/저장 테이블 설정 */}
@ -1327,6 +1427,60 @@ export function ModalRepeaterTableConfigPanel({
)}
</div>
</div>
{/* 6. 동적 데이터 소스 설정 */}
<div className="space-y-2 border-t pt-4">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium text-muted-foreground">
</Label>
<Switch
checked={col.dynamicDataSource?.enabled || false}
onCheckedChange={(checked) => toggleDynamicDataSource(index, checked)}
/>
</div>
<p className="text-[10px] text-muted-foreground">
(: 거래처별 , )
</p>
{col.dynamicDataSource?.enabled && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">
{col.dynamicDataSource.options.length}
</span>
<Button
size="sm"
variant="outline"
onClick={() => openDynamicSourceModal(index)}
className="h-7 text-xs"
>
</Button>
</div>
{/* 옵션 미리보기 */}
{col.dynamicDataSource.options.length > 0 && (
<div className="flex flex-wrap gap-1">
{col.dynamicDataSource.options.map((opt) => (
<span
key={opt.id}
className={cn(
"text-[10px] px-2 py-0.5 rounded-full",
col.dynamicDataSource?.defaultOptionId === opt.id
? "bg-primary text-primary-foreground"
: "bg-muted"
)}
>
{opt.label}
{col.dynamicDataSource?.defaultOptionId === opt.id && " (기본)"}
</span>
))}
</div>
)}
</div>
)}
</div>
</div>
))}
</div>
@ -1493,6 +1647,650 @@ export function ModalRepeaterTableConfigPanel({
</ul>
</div>
</div>
{/* 동적 데이터 소스 설정 모달 */}
<Dialog open={dynamicSourceModalOpen} onOpenChange={setDynamicSourceModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{editingDynamicSourceColumnIndex !== null && localConfig.columns?.[editingDynamicSourceColumnIndex] && (
<span className="text-primary ml-2">
({localConfig.columns[editingDynamicSourceColumnIndex].label})
</span>
)}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
{editingDynamicSourceColumnIndex !== null && localConfig.columns?.[editingDynamicSourceColumnIndex] && (
<div className="space-y-4">
{/* 옵션 목록 */}
<div className="space-y-3">
{(localConfig.columns[editingDynamicSourceColumnIndex].dynamicDataSource?.options || []).map((option, optIndex) => (
<div key={option.id} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-medium"> {optIndex + 1}</span>
{localConfig.columns![editingDynamicSourceColumnIndex].dynamicDataSource?.defaultOptionId === option.id && (
<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded"></span>
)}
</div>
<div className="flex items-center gap-1">
{localConfig.columns![editingDynamicSourceColumnIndex].dynamicDataSource?.defaultOptionId !== option.id && (
<Button
size="sm"
variant="ghost"
onClick={() => setDefaultDynamicSourceOption(editingDynamicSourceColumnIndex, option.id)}
className="h-6 text-[10px] px-2"
>
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => removeDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex)}
className="h-6 w-6 p-0 hover:bg-destructive/10 hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
{/* 옵션 라벨 */}
<div className="space-y-1">
<Label className="text-[10px]"> *</Label>
<Input
value={option.label}
onChange={(e) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { label: e.target.value })}
placeholder="예: 거래처별 단가"
className="h-8 text-xs"
/>
</div>
{/* 소스 타입 */}
<div className="space-y-1">
<Label className="text-[10px]"> *</Label>
<Select
value={option.sourceType}
onValueChange={(value) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
sourceType: value as "table" | "multiTable" | "api",
tableConfig: value === "table" ? { tableName: "", valueColumn: "", joinConditions: [] } : undefined,
multiTableConfig: value === "multiTable" ? { joinChain: [], valueColumn: "" } : undefined,
apiConfig: value === "api" ? { endpoint: "", parameterMappings: [], responseValueField: "" } : undefined,
})}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="table"> ( )</SelectItem>
<SelectItem value="multiTable"> (2 )</SelectItem>
<SelectItem value="api"> API </SelectItem>
</SelectContent>
</Select>
</div>
{/* 테이블 직접 조회 설정 */}
{option.sourceType === "table" && (
<div className="space-y-3 p-3 bg-blue-50 dark:bg-blue-950 rounded-md border border-blue-200 dark:border-blue-800">
<p className="text-xs font-medium"> </p>
{/* 참조 테이블 */}
<div className="space-y-1">
<Label className="text-[10px]"> *</Label>
<Select
value={option.tableConfig?.tableName || ""}
onValueChange={(value) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
tableConfig: { ...option.tableConfig!, tableName: value },
})}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{allTables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 값 컬럼 */}
<div className="space-y-1">
<Label className="text-[10px]"> ( ) *</Label>
<ReferenceColumnSelector
referenceTable={option.tableConfig?.tableName || ""}
value={option.tableConfig?.valueColumn || ""}
onChange={(value) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
tableConfig: { ...option.tableConfig!, valueColumn: value },
})}
/>
</div>
{/* 조인 조건 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> *</Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const newConditions = [...(option.tableConfig?.joinConditions || []), { sourceField: "", targetField: "" }];
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
tableConfig: { ...option.tableConfig!, joinConditions: newConditions },
});
}}
className="h-6 text-[10px] px-2"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{(option.tableConfig?.joinConditions || []).map((cond, condIndex) => (
<div key={condIndex} className="flex items-center gap-2 p-2 bg-background rounded">
<Select
value={cond.sourceField}
onValueChange={(value) => {
const newConditions = [...(option.tableConfig?.joinConditions || [])];
newConditions[condIndex] = { ...newConditions[condIndex], sourceField: value };
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
tableConfig: { ...option.tableConfig!, joinConditions: newConditions },
});
}}
>
<SelectTrigger className="h-7 text-[10px] flex-1">
<SelectValue placeholder="현재 행 필드" />
</SelectTrigger>
<SelectContent>
{(localConfig.columns || []).map((col) => (
<SelectItem key={col.field} value={col.field}>
{col.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[10px] text-muted-foreground">=</span>
<ReferenceColumnSelector
referenceTable={option.tableConfig?.tableName || ""}
value={cond.targetField}
onChange={(value) => {
const newConditions = [...(option.tableConfig?.joinConditions || [])];
newConditions[condIndex] = { ...newConditions[condIndex], targetField: value };
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
tableConfig: { ...option.tableConfig!, joinConditions: newConditions },
});
}}
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newConditions = [...(option.tableConfig?.joinConditions || [])];
newConditions.splice(condIndex, 1);
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
tableConfig: { ...option.tableConfig!, joinConditions: newConditions },
});
}}
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
{/* 테이블 복합 조인 설정 (2개 이상 테이블) */}
{option.sourceType === "multiTable" && (
<div className="space-y-3 p-3 bg-green-50 dark:bg-green-950 rounded-md border border-green-200 dark:border-green-800">
<div>
<p className="text-xs font-medium"> </p>
<p className="text-[10px] text-muted-foreground mt-1">
</p>
</div>
{/* 조인 체인 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> *</Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const newChain: MultiTableJoinStep[] = [
...(option.multiTableConfig?.joinChain || []),
{ tableName: "", joinCondition: { fromField: "", toField: "" }, outputField: "" }
];
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
});
}}
className="h-6 text-[10px] px-2"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{/* 시작점 안내 */}
<div className="p-2 bg-background rounded border-l-2 border-primary">
<p className="text-[10px] font-medium text-primary">시작: 현재 </p>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
{/* 조인 단계들 */}
{(option.multiTableConfig?.joinChain || []).map((step, stepIndex) => (
<div key={stepIndex} className="p-3 border rounded-md bg-background space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded-full bg-green-500 text-white flex items-center justify-center text-[10px] font-bold">
{stepIndex + 1}
</div>
<span className="text-xs font-medium"> {stepIndex + 1}</span>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newChain = [...(option.multiTableConfig?.joinChain || [])];
newChain.splice(stepIndex, 1);
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
});
}}
className="h-5 w-5 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
{/* 조인할 테이블 */}
<div className="space-y-1">
<Label className="text-[10px]"> *</Label>
<Select
value={step.tableName}
onValueChange={(value) => {
const newChain = [...(option.multiTableConfig?.joinChain || [])];
newChain[stepIndex] = { ...newChain[stepIndex], tableName: value };
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{allTables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 조인 조건 */}
<div className="grid grid-cols-[1fr,auto,1fr] gap-2 items-end">
<div className="space-y-1">
<Label className="text-[10px]">
{stepIndex === 0 ? "현재 행 필드" : "이전 단계 출력 필드"}
</Label>
{stepIndex === 0 ? (
<Select
value={step.joinCondition.fromField}
onValueChange={(value) => {
const newChain = [...(option.multiTableConfig?.joinChain || [])];
newChain[stepIndex] = {
...newChain[stepIndex],
joinCondition: { ...newChain[stepIndex].joinCondition, fromField: value }
};
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{(localConfig.columns || []).map((col) => (
<SelectItem key={col.field} value={col.field}>
{col.label} ({col.field})
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={step.joinCondition.fromField}
onChange={(e) => {
const newChain = [...(option.multiTableConfig?.joinChain || [])];
newChain[stepIndex] = {
...newChain[stepIndex],
joinCondition: { ...newChain[stepIndex].joinCondition, fromField: e.target.value }
};
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
});
}}
placeholder={option.multiTableConfig?.joinChain[stepIndex - 1]?.outputField || "이전 출력 필드"}
className="h-8 text-xs"
/>
)}
</div>
<div className="flex items-center justify-center pb-1">
<span className="text-xs text-muted-foreground">=</span>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<ReferenceColumnSelector
referenceTable={step.tableName}
value={step.joinCondition.toField}
onChange={(value) => {
const newChain = [...(option.multiTableConfig?.joinChain || [])];
newChain[stepIndex] = {
...newChain[stepIndex],
joinCondition: { ...newChain[stepIndex].joinCondition, toField: value }
};
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
});
}}
/>
</div>
</div>
{/* 다음 단계로 전달할 필드 */}
<div className="space-y-1">
<Label className="text-[10px]"> ()</Label>
<ReferenceColumnSelector
referenceTable={step.tableName}
value={step.outputField || ""}
onChange={(value) => {
const newChain = [...(option.multiTableConfig?.joinChain || [])];
newChain[stepIndex] = { ...newChain[stepIndex], outputField: value };
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
});
}}
/>
<p className="text-[10px] text-muted-foreground">
{stepIndex < (option.multiTableConfig?.joinChain.length || 0) - 1
? "다음 조인 단계에서 사용할 필드"
: "마지막 단계면 비워두세요"}
</p>
</div>
{/* 조인 미리보기 */}
{step.tableName && step.joinCondition.fromField && step.joinCondition.toField && (
<div className="p-2 bg-muted/50 rounded text-[10px] font-mono">
<span className="text-blue-600 dark:text-blue-400">
{stepIndex === 0 ? "현재행" : option.multiTableConfig?.joinChain[stepIndex - 1]?.tableName}
</span>
<span className="text-muted-foreground">.{step.joinCondition.fromField}</span>
<span className="mx-2 text-green-600 dark:text-green-400">=</span>
<span className="text-green-600 dark:text-green-400">{step.tableName}</span>
<span className="text-muted-foreground">.{step.joinCondition.toField}</span>
{step.outputField && (
<span className="ml-2 text-purple-600 dark:text-purple-400">
{step.outputField}
</span>
)}
</div>
)}
</div>
))}
{/* 조인 체인이 없을 때 안내 */}
{(!option.multiTableConfig?.joinChain || option.multiTableConfig.joinChain.length === 0) && (
<div className="p-4 border-2 border-dashed rounded-lg text-center">
<p className="text-xs text-muted-foreground mb-2">
</p>
<p className="text-[10px] text-muted-foreground">
"조인 추가"
</p>
</div>
)}
</div>
{/* 최종 값 컬럼 */}
{option.multiTableConfig?.joinChain && option.multiTableConfig.joinChain.length > 0 && (
<div className="space-y-1 pt-2 border-t">
<Label className="text-[10px]"> ( ) *</Label>
<ReferenceColumnSelector
referenceTable={option.multiTableConfig.joinChain[option.multiTableConfig.joinChain.length - 1]?.tableName || ""}
value={option.multiTableConfig.valueColumn || ""}
onChange={(value) => {
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
multiTableConfig: { ...option.multiTableConfig!, valueColumn: value },
});
}}
/>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
)}
{/* 전체 조인 경로 미리보기 */}
{option.multiTableConfig?.joinChain && option.multiTableConfig.joinChain.length > 0 && (
<div className="p-3 bg-muted rounded-md">
<p className="text-[10px] font-medium mb-2"> </p>
<div className="text-[10px] font-mono space-y-1">
{option.multiTableConfig.joinChain.map((step, idx) => (
<div key={idx} className="flex items-center gap-1">
{idx === 0 && (
<>
<span className="text-blue-600"></span>
<span>.{step.joinCondition.fromField}</span>
<span className="text-muted-foreground mx-1"></span>
</>
)}
<span className="text-green-600">{step.tableName}</span>
<span>.{step.joinCondition.toField}</span>
{step.outputField && idx < option.multiTableConfig!.joinChain.length - 1 && (
<>
<span className="text-muted-foreground mx-1"></span>
<span className="text-purple-600">{step.outputField}</span>
</>
)}
</div>
))}
{option.multiTableConfig.valueColumn && (
<div className="pt-1 border-t mt-1">
<span className="text-orange-600"> : </span>
<span>{option.multiTableConfig.joinChain[option.multiTableConfig.joinChain.length - 1]?.tableName}.{option.multiTableConfig.valueColumn}</span>
</div>
)}
</div>
</div>
)}
</div>
)}
{/* API 호출 설정 */}
{option.sourceType === "api" && (
<div className="space-y-3 p-3 bg-purple-50 dark:bg-purple-950 rounded-md border border-purple-200 dark:border-purple-800">
<p className="text-xs font-medium">API </p>
<p className="text-[10px] text-muted-foreground">
API로
</p>
{/* API 엔드포인트 */}
<div className="space-y-1">
<Label className="text-[10px]">API *</Label>
<Input
value={option.apiConfig?.endpoint || ""}
onChange={(e) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
apiConfig: { ...option.apiConfig!, endpoint: e.target.value },
})}
placeholder="/api/price/customer"
className="h-8 text-xs font-mono"
/>
</div>
{/* HTTP 메서드 */}
<div className="space-y-1">
<Label className="text-[10px]">HTTP </Label>
<Select
value={option.apiConfig?.method || "GET"}
onValueChange={(value) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
apiConfig: { ...option.apiConfig!, method: value as "GET" | "POST" },
})}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
</SelectContent>
</Select>
</div>
{/* 파라미터 매핑 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> *</Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const newMappings = [...(option.apiConfig?.parameterMappings || []), { paramName: "", sourceField: "" }];
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
apiConfig: { ...option.apiConfig!, parameterMappings: newMappings },
});
}}
className="h-6 text-[10px] px-2"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{(option.apiConfig?.parameterMappings || []).map((mapping, mapIndex) => (
<div key={mapIndex} className="flex items-center gap-2 p-2 bg-background rounded">
<Input
value={mapping.paramName}
onChange={(e) => {
const newMappings = [...(option.apiConfig?.parameterMappings || [])];
newMappings[mapIndex] = { ...newMappings[mapIndex], paramName: e.target.value };
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
apiConfig: { ...option.apiConfig!, parameterMappings: newMappings },
});
}}
placeholder="파라미터명"
className="h-7 text-[10px] flex-1"
/>
<span className="text-[10px] text-muted-foreground">=</span>
<Select
value={mapping.sourceField}
onValueChange={(value) => {
const newMappings = [...(option.apiConfig?.parameterMappings || [])];
newMappings[mapIndex] = { ...newMappings[mapIndex], sourceField: value };
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
apiConfig: { ...option.apiConfig!, parameterMappings: newMappings },
});
}}
>
<SelectTrigger className="h-7 text-[10px] flex-1">
<SelectValue placeholder="현재 행 필드" />
</SelectTrigger>
<SelectContent>
{(localConfig.columns || []).map((col) => (
<SelectItem key={col.field} value={col.field}>
{col.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newMappings = [...(option.apiConfig?.parameterMappings || [])];
newMappings.splice(mapIndex, 1);
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
apiConfig: { ...option.apiConfig!, parameterMappings: newMappings },
});
}}
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
{/* 응답 값 필드 */}
<div className="space-y-1">
<Label className="text-[10px]"> *</Label>
<Input
value={option.apiConfig?.responseValueField || ""}
onChange={(e) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
apiConfig: { ...option.apiConfig!, responseValueField: e.target.value },
})}
placeholder="price (또는 data.price)"
className="h-8 text-xs font-mono"
/>
<p className="text-[10px] text-muted-foreground">
API ( 지원: data.price)
</p>
</div>
</div>
)}
</div>
))}
{/* 옵션 추가 버튼 */}
<Button
variant="outline"
onClick={() => addDynamicSourceOption(editingDynamicSourceColumnIndex)}
className="w-full h-10"
>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
{/* 안내 */}
<div className="p-3 bg-muted rounded-md text-xs text-muted-foreground">
<p className="font-medium mb-1"> </p>
<ul className="space-y-1 text-[10px]">
<li>- <strong> </strong>: customer_item_price </li>
<li>- <strong> </strong>: item_info </li>
<li>- <strong> </strong>: API로 </li>
</ul>
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setDynamicSourceModalOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -4,7 +4,8 @@ import React, { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Trash2, ChevronDown, Check } from "lucide-react";
import { RepeaterColumnConfig } from "./types";
import { cn } from "@/lib/utils";
@ -14,6 +15,9 @@ interface RepeaterTableProps {
onDataChange: (newData: any[]) => void;
onRowChange: (index: number, newRow: any) => void;
onRowDelete: (index: number) => void;
// 동적 데이터 소스 관련
activeDataSources?: Record<string, string>; // 컬럼별 현재 활성화된 데이터 소스 ID
onDataSourceChange?: (columnField: string, optionId: string) => void; // 데이터 소스 변경 콜백
}
export function RepeaterTable({
@ -22,11 +26,16 @@ export function RepeaterTable({
onDataChange,
onRowChange,
onRowDelete,
activeDataSources = {},
onDataSourceChange,
}: RepeaterTableProps) {
const [editingCell, setEditingCell] = useState<{
rowIndex: number;
field: string;
} | null>(null);
// 동적 데이터 소스 Popover 열림 상태
const [openPopover, setOpenPopover] = useState<string | null>(null);
// 데이터 변경 감지 (필요시 활성화)
// useEffect(() => {
@ -144,16 +153,79 @@ export function RepeaterTable({
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
#
</th>
{columns.map((col) => (
{columns.map((col) => {
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
const activeOption = hasDynamicSource
? col.dynamicDataSource!.options.find(opt => opt.id === activeOptionId) || col.dynamicDataSource!.options[0]
: null;
return (
<th
key={col.field}
className="px-4 py-2 text-left font-medium text-muted-foreground"
style={{ width: col.width }}
>
{hasDynamicSource ? (
<Popover
open={openPopover === col.field}
onOpenChange={(open) => setOpenPopover(open ? col.field : null)}
>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center gap-1 hover:text-primary transition-colors",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded px-1 -mx-1"
)}
>
<span>{col.label}</span>
<ChevronDown className="h-3 w-3 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-auto min-w-[160px] p-1"
align="start"
sideOffset={4}
>
<div className="text-[10px] text-muted-foreground px-2 py-1 border-b mb-1">
</div>
{col.dynamicDataSource!.options.map((option) => (
<button
key={option.id}
type="button"
onClick={() => {
onDataSourceChange?.(col.field, option.id);
setOpenPopover(null);
}}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm",
"hover:bg-accent hover:text-accent-foreground transition-colors",
"focus:outline-none focus-visible:bg-accent",
activeOption?.id === option.id && "bg-accent/50"
)}
>
<Check
className={cn(
"h-3 w-3",
activeOption?.id === option.id ? "opacity-100" : "opacity-0"
)}
/>
<span>{option.label}</span>
</button>
))}
</PopoverContent>
</Popover>
) : (
<>
{col.label}
{col.required && <span className="text-destructive ml-1">*</span>}
</>
)}
</th>
))}
);
})}
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-20">
</th>

View File

@ -10,7 +10,7 @@ export interface ModalRepeaterTableProps {
sourceColumnLabels?: Record<string, string>; // 모달 컬럼 라벨 (컬럼명 -> 표시 라벨)
sourceSearchFields?: string[]; // 검색 가능한 필드들
// 🆕 저장 대상 테이블 설정
// 저장 대상 테이블 설정
targetTable?: string; // 저장할 테이블 (예: "sales_order_mng")
// 모달 설정
@ -25,14 +25,14 @@ export interface ModalRepeaterTableProps {
calculationRules?: CalculationRule[]; // 자동 계산 규칙
// 데이터
value: any[]; // 현재 추가된 항목들
onChange: (newData: any[]) => void; // 데이터 변경 콜백
value: Record<string, unknown>[]; // 현재 추가된 항목들
onChange: (newData: Record<string, unknown>[]) => void; // 데이터 변경 콜백
// 중복 체크
uniqueField?: string; // 중복 체크할 필드 (예: "item_code")
// 필터링
filterCondition?: Record<string, any>;
filterCondition?: Record<string, unknown>;
companyCode?: string;
// 스타일
@ -47,11 +47,92 @@ export interface RepeaterColumnConfig {
calculated?: boolean; // 계산 필드 여부
width?: string; // 컬럼 너비
required?: boolean; // 필수 입력 여부
defaultValue?: any; // 기본값
defaultValue?: string | number | boolean; // 기본값
selectOptions?: { value: string; label: string }[]; // select일 때 옵션
// 🆕 컬럼 매핑 설정
// 컬럼 매핑 설정
mapping?: ColumnMapping; // 이 컬럼의 데이터를 어디서 가져올지 설정
// 동적 데이터 소스 (컬럼 헤더 클릭으로 데이터 소스 전환)
dynamicDataSource?: DynamicDataSourceConfig;
}
/**
*
*
* : 거래처별 , ,
*/
export interface DynamicDataSourceConfig {
enabled: boolean;
options: DynamicDataSourceOption[];
defaultOptionId?: string; // 기본 선택 옵션 ID
}
/**
*
* /API에서
*/
export interface DynamicDataSourceOption {
id: string;
label: string; // 표시 라벨 (예: "거래처별 단가")
// 조회 방식
sourceType: "table" | "multiTable" | "api";
// 테이블 직접 조회 (단순 조인 - 1개 테이블)
tableConfig?: {
tableName: string; // 참조 테이블명
valueColumn: string; // 가져올 값 컬럼
joinConditions: {
sourceField: string; // 현재 행의 필드
targetField: string; // 참조 테이블의 필드
}[];
};
// 테이블 복합 조인 (2개 이상 테이블 조인)
multiTableConfig?: {
// 조인 체인 정의 (순서대로 조인)
joinChain: MultiTableJoinStep[];
// 최종적으로 가져올 값 컬럼 (마지막 테이블에서)
valueColumn: string;
};
// 전용 API 호출 (복잡한 다중 조인)
apiConfig?: {
endpoint: string; // API 엔드포인트 (예: "/api/price/customer")
method?: "GET" | "POST"; // HTTP 메서드 (기본: GET)
parameterMappings: {
paramName: string; // API 파라미터명
sourceField: string; // 현재 행의 필드
}[];
responseValueField: string; // 응답에서 값을 가져올 필드
};
}
/**
*
* : item_info.item_number customer_item.item_code customer_item.id customer_item_price.customer_item_id
*/
export interface MultiTableJoinStep {
// 조인할 테이블
tableName: string;
// 조인 조건
joinCondition: {
// 이전 단계의 필드 (첫 번째 단계는 현재 행의 필드)
fromField: string;
// 이 테이블의 필드
toField: string;
};
// 다음 단계로 전달할 필드 (다음 조인에 사용)
outputField?: string;
// 추가 필터 조건 (선택사항)
additionalFilters?: {
field: string;
operator: "=" | "!=" | ">" | "<" | ">=" | "<=";
value: string | number | boolean;
// 값이 현재 행에서 오는 경우
valueFromField?: string;
}[];
}
/**
@ -101,11 +182,10 @@ export interface ItemSelectionModalProps {
sourceColumns: string[];
sourceSearchFields?: string[];
multiSelect?: boolean;
filterCondition?: Record<string, any>;
filterCondition?: Record<string, unknown>;
modalTitle: string;
alreadySelected: any[]; // 이미 선택된 항목들 (중복 방지용)
alreadySelected: Record<string, unknown>[]; // 이미 선택된 항목들 (중복 방지용)
uniqueField?: string;
onSelect: (items: any[]) => void;
onSelect: (items: Record<string, unknown>[]) => void;
columnLabels?: Record<string, string>; // 컬럼명 -> 라벨명 매핑
}

View File

@ -55,7 +55,8 @@ export function RepeatScreenModalComponent({
...props
}: RepeatScreenModalComponentProps) {
// props에서도 groupedData를 추출 (DynamicWebTypeRenderer에서 전달될 수 있음)
const groupedData = propsGroupedData || (props as any).groupedData;
// DynamicComponentRenderer에서는 _groupedData로 전달됨
const groupedData = propsGroupedData || (props as any).groupedData || (props as any)._groupedData;
const componentConfig = {
...config,
...component?.config,
@ -99,25 +100,99 @@ export function RepeatScreenModalComponent({
contentRowId: string;
} | null>(null);
// 🆕 v3.13: 외부에서 저장 트리거 가능하도록 이벤트 리스너 추가
useEffect(() => {
const handleTriggerSave = async (event: Event) => {
if (!(event instanceof CustomEvent)) return;
console.log("[RepeatScreenModal] triggerRepeatScreenModalSave 이벤트 수신");
try {
setIsSaving(true);
// 기존 데이터 저장
if (cardMode === "withTable") {
await saveGroupedData();
} else {
await saveSimpleData();
}
// 외부 테이블 데이터 저장
await saveExternalTableData();
// 연동 저장 처리 (syncSaves)
await processSyncSaves();
console.log("[RepeatScreenModal] 외부 트리거 저장 완료");
// 저장 완료 이벤트 발생
window.dispatchEvent(new CustomEvent("repeatScreenModalSaveComplete", {
detail: { success: true }
}));
// 성공 콜백 실행
if (event.detail?.onSuccess) {
event.detail.onSuccess();
}
} catch (error: any) {
console.error("[RepeatScreenModal] 외부 트리거 저장 실패:", error);
// 저장 실패 이벤트 발생
window.dispatchEvent(new CustomEvent("repeatScreenModalSaveComplete", {
detail: { success: false, error: error.message }
}));
// 실패 콜백 실행
if (event.detail?.onError) {
event.detail.onError(error);
}
} finally {
setIsSaving(false);
}
};
window.addEventListener("triggerRepeatScreenModalSave", handleTriggerSave as EventListener);
return () => {
window.removeEventListener("triggerRepeatScreenModalSave", handleTriggerSave as EventListener);
};
}, [cardMode, groupedCardsData, externalTableData, contentRows]);
// 🆕 v3.9: beforeFormSave 이벤트 핸들러 - ButtonPrimary 저장 시 externalTableData를 formData에 병합
useEffect(() => {
const handleBeforeFormSave = (event: Event) => {
if (!(event instanceof CustomEvent) || !event.detail?.formData) return;
console.log("[RepeatScreenModal] beforeFormSave 이벤트 수신");
console.log("[RepeatScreenModal] beforeFormSave - externalTableData:", externalTableData);
console.log("[RepeatScreenModal] beforeFormSave - groupedCardsData:", groupedCardsData.length, "개 카드");
// 외부 테이블 데이터에서 dirty 행만 추출하여 저장 데이터 준비
const saveDataByTable: Record<string, any[]> = {};
for (const [key, rows] of Object.entries(externalTableData)) {
// key 형식: cardId-contentRowId
const keyParts = key.split("-");
const cardId = keyParts.slice(0, -1).join("-"); // contentRowId를 제외한 나머지가 cardId
// contentRow 찾기
const contentRow = contentRows.find((r) => key.includes(r.id));
if (!contentRow?.tableDataSource?.enabled) continue;
// 🆕 v3.13: 해당 카드의 대표 데이터 찾기 (joinConditions의 targetKey 값을 가져오기 위해)
const card = groupedCardsData.find((c) => c._cardId === cardId);
const representativeData = card?._representativeData || {};
const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable;
// dirty 행만 필터링 (삭제된 행 제외)
const dirtyRows = rows.filter((row) => row._isDirty && !row._isDeleted);
// dirty 행 또는 새로운 행 필터링 (삭제된 행 제외)
// 🆕 v3.13: _isNew 행도 포함 (새로 추가된 행은 _isDirty가 없을 수 있음)
const dirtyRows = rows.filter((row) => (row._isDirty || row._isNew) && !row._isDeleted);
console.log(`[RepeatScreenModal] beforeFormSave - ${targetTable} 행 필터링:`, {
totalRows: rows.length,
dirtyRows: dirtyRows.length,
rowDetails: rows.map(r => ({ _isDirty: r._isDirty, _isNew: r._isNew, _isDeleted: r._isDeleted }))
});
if (dirtyRows.length === 0) continue;
@ -126,8 +201,9 @@ export function RepeatScreenModalComponent({
.filter((col) => col.editable)
.map((col) => col.field);
const joinKeys = (contentRow.tableDataSource.joinConditions || [])
.map((cond) => cond.sourceKey);
// 🆕 v3.13: joinConditions에서 sourceKey (저장 대상 테이블의 FK 컬럼) 추출
const joinConditions = contentRow.tableDataSource.joinConditions || [];
const joinKeys = joinConditions.map((cond) => cond.sourceKey);
const allowedFields = [...new Set([...editableFields, ...joinKeys])];
@ -145,6 +221,17 @@ export function RepeatScreenModalComponent({
}
}
// 🆕 v3.13: joinConditions를 사용하여 FK 값 자동 채우기
// 예: sales_order_id (sourceKey) = card의 id (targetKey)
for (const joinCond of joinConditions) {
const { sourceKey, targetKey } = joinCond;
// sourceKey가 저장 데이터에 없거나 null인 경우, 카드의 대표 데이터에서 targetKey 값을 가져옴
if (!saveData[sourceKey] && representativeData[targetKey] !== undefined) {
saveData[sourceKey] = representativeData[targetKey];
console.log(`[RepeatScreenModal] beforeFormSave - FK 자동 채우기: ${sourceKey} = ${representativeData[targetKey]} (from card.${targetKey})`);
}
}
// _isNew 플래그 유지
saveData._isNew = row._isNew;
saveData._targetTable = targetTable;
@ -590,18 +677,26 @@ export function RepeatScreenModalComponent({
if (!hasExternalAggregation) return;
// contentRows에서 외부 테이블 데이터 소스가 있는 table 타입 행 찾기
const tableRowWithExternalSource = contentRows.find(
// contentRows에서 외부 테이블 데이터 소스가 있는 모든 table 타입 행 찾기
const tableRowsWithExternalSource = contentRows.filter(
(row) => row.type === "table" && row.tableDataSource?.enabled
);
if (!tableRowWithExternalSource) return;
if (tableRowsWithExternalSource.length === 0) return;
// 각 카드의 집계 재계산
const updatedCards = groupedCardsData.map((card) => {
const key = `${card._cardId}-${tableRowWithExternalSource.id}`;
// 🆕 v3.11: 테이블 행 ID별로 외부 데이터를 구분하여 저장
const externalRowsByTableId: Record<string, any[]> = {};
const allExternalRows: any[] = [];
for (const tableRow of tableRowsWithExternalSource) {
const key = `${card._cardId}-${tableRow.id}`;
// 🆕 v3.7: 삭제된 행은 집계에서 제외
const externalRows = (extData[key] || []).filter((row) => !row._isDeleted);
const rows = (extData[key] || []).filter((row) => !row._isDeleted);
externalRowsByTableId[tableRow.id] = rows;
allExternalRows.push(...rows);
}
// 집계 재계산
const newAggregations: Record<string, number> = {};
@ -616,7 +711,7 @@ export function RepeatScreenModalComponent({
if (isExternalTable) {
// 외부 테이블 집계
newAggregations[agg.resultField] = calculateColumnAggregation(
externalRows,
allExternalRows,
agg.sourceField || "",
agg.type || "sum"
);
@ -626,12 +721,28 @@ export function RepeatScreenModalComponent({
calculateColumnAggregation(card._rows, agg.sourceField || "", agg.type || "sum");
}
} else if (sourceType === "formula" && agg.formula) {
// 🆕 v3.11: externalTableRefs 기반으로 필터링된 외부 데이터 사용
let filteredExternalRows: any[];
if (agg.externalTableRefs && agg.externalTableRefs.length > 0) {
// 특정 테이블만 참조
filteredExternalRows = [];
for (const tableId of agg.externalTableRefs) {
if (externalRowsByTableId[tableId]) {
filteredExternalRows.push(...externalRowsByTableId[tableId]);
}
}
} else {
// 모든 외부 테이블 데이터 사용 (기존 동작)
filteredExternalRows = allExternalRows;
}
// 가상 집계 (연산식) - 외부 테이블 데이터 포함하여 재계산
newAggregations[agg.resultField] = evaluateFormulaWithContext(
agg.formula,
card._representativeData,
card._rows,
externalRows,
filteredExternalRows,
newAggregations // 이전 집계 결과 참조
);
}
@ -654,8 +765,8 @@ export function RepeatScreenModalComponent({
});
};
// 🆕 v3.1: 외부 테이블 행 추가
const handleAddExternalRow = (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => {
// 🆕 v3.1: 외부 테이블 행 추가 (v3.13: 자동 채번 기능 추가)
const handleAddExternalRow = async (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => {
const key = `${cardId}-${contentRowId}`;
const card = groupedCardsData.find((c) => c._cardId === cardId) || cardsData.find((c) => c._cardId === cardId);
const representativeData = (card as GroupedCardData)?._representativeData || card || {};
@ -707,6 +818,41 @@ export function RepeatScreenModalComponent({
}
}
// 🆕 v3.13: 자동 채번 처리
const rowNumbering = contentRow.tableCrud?.rowNumbering;
console.log("[RepeatScreenModal] 채번 설정 확인:", {
tableCrud: contentRow.tableCrud,
rowNumbering,
enabled: rowNumbering?.enabled,
targetColumn: rowNumbering?.targetColumn,
numberingRuleId: rowNumbering?.numberingRuleId,
});
if (rowNumbering?.enabled && rowNumbering.targetColumn && rowNumbering.numberingRuleId) {
try {
console.log("[RepeatScreenModal] 자동 채번 시작:", {
targetColumn: rowNumbering.targetColumn,
numberingRuleId: rowNumbering.numberingRuleId,
});
// 채번 API 호출 (allocate: 실제 시퀀스 증가)
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
const response = await allocateNumberingCode(rowNumbering.numberingRuleId);
if (response.success && response.data) {
newRowData[rowNumbering.targetColumn] = response.data.generatedCode;
console.log("[RepeatScreenModal] 자동 채번 완료:", {
column: rowNumbering.targetColumn,
generatedCode: response.data.generatedCode,
});
} else {
console.warn("[RepeatScreenModal] 채번 실패:", response);
}
} catch (error) {
console.error("[RepeatScreenModal] 채번 API 호출 실패:", error);
}
}
console.log("[RepeatScreenModal] 새 행 추가:", {
cardId,
contentRowId,
@ -1009,26 +1155,70 @@ export function RepeatScreenModalComponent({
}
};
// 🆕 v3.1: 외부 테이블 행 삭제 실행 (소프트 삭제 - _isDeleted 플래그 설정)
const handleDeleteExternalRow = (cardId: string, rowId: string, contentRowId: string) => {
// 🆕 v3.14: 외부 테이블 행 삭제 실행 (즉시 DELETE API 호출)
const handleDeleteExternalRow = async (cardId: string, rowId: string, contentRowId: string) => {
const key = `${cardId}-${contentRowId}`;
const rows = externalTableData[key] || [];
const targetRow = rows.find((row) => row._rowId === rowId);
// 기존 DB 데이터인 경우 (id가 있는 경우) 즉시 삭제
if (targetRow?._originalData?.id) {
try {
const contentRow = contentRows.find((r) => r.id === contentRowId);
const targetTable = contentRow?.tableCrud?.targetTable || contentRow?.tableDataSource?.sourceTable;
if (!targetTable) {
console.error("[RepeatScreenModal] 삭제 대상 테이블을 찾을 수 없습니다.");
return;
}
console.log(`[RepeatScreenModal] DELETE API 호출: ${targetTable}, id=${targetRow._originalData.id}`);
// 백엔드는 배열 형태의 데이터를 기대함
await apiClient.request({
method: "DELETE",
url: `/table-management/tables/${targetTable}/delete`,
data: [{ id: targetRow._originalData.id }],
});
console.log(`[RepeatScreenModal] DELETE 성공: ${targetTable}, id=${targetRow._originalData.id}`);
// 성공 시 UI에서 완전히 제거
setExternalTableData((prev) => {
const newData = {
...prev,
[key]: (prev[key] || []).map((row) =>
row._rowId === rowId
? { ...row, _isDeleted: true, _isDirty: true }
: row
),
[key]: prev[key].filter((row) => row._rowId !== rowId),
};
// 🆕 v3.5: 행 삭제 시 집계 재계산 (삭제된 행 제외)
// 행 삭제 시 집계 재계산
setTimeout(() => {
recalculateAggregationsWithExternalData(newData);
}, 0);
return newData;
});
} catch (error: any) {
console.error(`[RepeatScreenModal] DELETE 실패:`, error.response?.data || error.message);
// 에러 시에도 다이얼로그 닫기
}
} else {
// 새로 추가된 행 (아직 DB에 없음) - UI에서만 제거
console.log(`[RepeatScreenModal] 새 행 삭제 (DB 없음): rowId=${rowId}`);
setExternalTableData((prev) => {
const newData = {
...prev,
[key]: prev[key].filter((row) => row._rowId !== rowId),
};
// 행 삭제 시 집계 재계산
setTimeout(() => {
recalculateAggregationsWithExternalData(newData);
}, 0);
return newData;
});
}
setDeleteConfirmOpen(false);
setPendingDeleteInfo(null);
};
@ -1323,8 +1513,13 @@ export function RepeatScreenModalComponent({
for (const fn of extAggFunctions) {
const regex = new RegExp(`${fn}\\(\\{(\\w+)\\}\\)`, "g");
expression = expression.replace(regex, (match, fieldName) => {
if (!externalRows || externalRows.length === 0) return "0";
if (!externalRows || externalRows.length === 0) {
console.log(`[SUM_EXT] ${fieldName}: 외부 데이터 없음`);
return "0";
}
const values = externalRows.map((row) => Number(row[fieldName]) || 0);
const sum = values.reduce((a, b) => a + b, 0);
console.log(`[SUM_EXT] ${fieldName}: ${externalRows.length}개 행, 값들:`, values, `합계: ${sum}`);
const baseFn = fn.replace("_EXT", "");
switch (baseFn) {
case "SUM":
@ -1525,6 +1720,9 @@ export function RepeatScreenModalComponent({
// 🆕 v3.1: 외부 테이블 데이터 저장
await saveExternalTableData();
// 🆕 v3.12: 연동 저장 처리 (syncSaves)
await processSyncSaves();
alert("저장되었습니다.");
} catch (error: any) {
console.error("저장 실패:", error);
@ -1582,6 +1780,102 @@ export function RepeatScreenModalComponent({
});
};
// 🆕 v3.12: 연동 저장 처리 (syncSaves)
const processSyncSaves = async () => {
const syncPromises: Promise<void>[] = [];
// contentRows에서 syncSaves가 설정된 테이블 행 찾기
for (const contentRow of contentRows) {
if (contentRow.type !== "table") continue;
if (!contentRow.tableCrud?.syncSaves?.length) continue;
const sourceTable = contentRow.tableDataSource?.sourceTable;
if (!sourceTable) continue;
// 이 테이블 행의 모든 카드 데이터 수집
for (const card of groupedCardsData) {
const key = `${card._cardId}-${contentRow.id}`;
const rows = (externalTableData[key] || []).filter((row) => !row._isDeleted);
// 각 syncSave 설정 처리
for (const syncSave of contentRow.tableCrud.syncSaves) {
if (!syncSave.enabled) continue;
if (!syncSave.sourceColumn || !syncSave.targetTable || !syncSave.targetColumn) continue;
// 조인 키 값 수집 (중복 제거)
const joinKeyValues = new Set<string | number>();
for (const row of rows) {
const keyValue = row[syncSave.joinKey.sourceField];
if (keyValue !== undefined && keyValue !== null) {
joinKeyValues.add(keyValue);
}
}
// 각 조인 키별로 집계 계산 및 업데이트
for (const keyValue of joinKeyValues) {
// 해당 조인 키에 해당하는 행들만 필터링
const filteredRows = rows.filter(
(row) => row[syncSave.joinKey.sourceField] === keyValue
);
// 집계 계산
let aggregatedValue: number = 0;
const values = filteredRows.map((row) => Number(row[syncSave.sourceColumn]) || 0);
switch (syncSave.aggregationType) {
case "sum":
aggregatedValue = values.reduce((a, b) => a + b, 0);
break;
case "count":
aggregatedValue = values.length;
break;
case "avg":
aggregatedValue = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
break;
case "min":
aggregatedValue = values.length > 0 ? Math.min(...values) : 0;
break;
case "max":
aggregatedValue = values.length > 0 ? Math.max(...values) : 0;
break;
case "latest":
aggregatedValue = values.length > 0 ? values[values.length - 1] : 0;
break;
}
console.log(`[SyncSave] ${sourceTable}.${syncSave.sourceColumn}${syncSave.targetTable}.${syncSave.targetColumn}`, {
joinKey: keyValue,
aggregationType: syncSave.aggregationType,
values,
aggregatedValue,
});
// 대상 테이블 업데이트
syncPromises.push(
apiClient
.put(`/table-management/tables/${syncSave.targetTable}/data/${keyValue}`, {
[syncSave.targetColumn]: aggregatedValue,
})
.then(() => {
console.log(`[SyncSave] 업데이트 완료: ${syncSave.targetTable}.${syncSave.targetColumn} = ${aggregatedValue} (id=${keyValue})`);
})
.catch((err) => {
console.error(`[SyncSave] 업데이트 실패:`, err);
throw err;
})
);
}
}
}
}
if (syncPromises.length > 0) {
console.log(`[SyncSave] ${syncPromises.length}개 연동 저장 처리 중...`);
await Promise.all(syncPromises);
console.log(`[SyncSave] 연동 저장 완료`);
}
};
// 🆕 v3.1: Footer 버튼 클릭 핸들러
const handleFooterButtonClick = async (btn: FooterButtonConfig) => {
switch (btn.action) {
@ -1928,27 +2222,10 @@ export function RepeatScreenModalComponent({
// 🆕 v3.1: 외부 테이블 데이터 소스 사용
<div className="border rounded-lg overflow-hidden">
{/* 테이블 헤더 영역: 제목 + 버튼들 */}
{(contentRow.tableTitle || contentRow.tableCrud?.allowSave || contentRow.tableCrud?.allowCreate) && (
{(contentRow.tableTitle || contentRow.tableCrud?.allowCreate) && (
<div className="px-4 py-2 bg-muted/30 border-b text-sm font-medium flex items-center justify-between">
<span>{contentRow.tableTitle || ""}</span>
<div className="flex items-center gap-2">
{/* 저장 버튼 - allowSave가 true일 때만 표시 */}
{contentRow.tableCrud?.allowSave && (
<Button
variant="default"
size="sm"
onClick={() => handleTableAreaSave(card._cardId, contentRow.id, contentRow)}
disabled={isSaving || !(externalTableData[`${card._cardId}-${contentRow.id}`] || []).some((r: any) => r._isDirty)}
className="h-7 text-xs gap-1"
>
{isSaving ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Save className="h-3 w-3" />
)}
{contentRow.tableCrud?.saveButtonLabel || "저장"}
</Button>
)}
{/* 추가 버튼 */}
{contentRow.tableCrud?.allowCreate && (
<Button
@ -1968,7 +2245,8 @@ export function RepeatScreenModalComponent({
{contentRow.showTableHeader !== false && (
<TableHeader>
<TableRow className="bg-muted/50">
{(contentRow.tableColumns || []).map((col) => (
{/* 🆕 v3.13: hidden 컬럼 필터링 */}
{(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => (
<TableHead
key={col.id}
style={{ width: col.width }}
@ -1987,7 +2265,7 @@ export function RepeatScreenModalComponent({
{(externalTableData[`${card._cardId}-${contentRow.id}`] || []).length === 0 ? (
<TableRow>
<TableCell
colSpan={(contentRow.tableColumns?.length || 0) + (contentRow.tableCrud?.allowDelete ? 1 : 0)}
colSpan={(contentRow.tableColumns?.filter(col => !col.hidden)?.length || 0) + (contentRow.tableCrud?.allowDelete ? 1 : 0)}
className="text-center py-8 text-muted-foreground"
>
.
@ -2003,7 +2281,8 @@ export function RepeatScreenModalComponent({
row._isDeleted && "bg-destructive/10 opacity-60"
)}
>
{(contentRow.tableColumns || []).map((col) => (
{/* 🆕 v3.13: hidden 컬럼 필터링 */}
{(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => (
<TableCell
key={`${row._rowId}-${col.id}`}
className={cn(

View File

@ -188,10 +188,6 @@ export interface TableCrudConfig {
allowUpdate: boolean; // 행 수정 허용
allowDelete: boolean; // 행 삭제 허용
// 🆕 v3.5: 테이블 영역 저장 버튼
allowSave?: boolean; // 테이블 영역에 저장 버튼 표시
saveButtonLabel?: string; // 저장 버튼 라벨 (기본: "저장")
// 신규 행 기본값
newRowDefaults?: Record<string, string>; // 기본값 (예: { status: "READY", sales_order_id: "{id}" })
@ -203,6 +199,54 @@ export interface TableCrudConfig {
// 저장 대상 테이블 (외부 데이터 소스 사용 시)
targetTable?: string; // 저장할 테이블 (기본: tableDataSource.sourceTable)
// 🆕 v3.12: 연동 저장 설정 (모달 전체 저장 시 다른 테이블에도 동기화)
syncSaves?: SyncSaveConfig[];
// 🆕 v3.13: 행 추가 시 자동 채번 설정
rowNumbering?: RowNumberingConfig;
}
/**
* 🆕 v3.13: 테이블
* "추가"
*
* :
* - (shipment_plan_no)
* - (invoice_no)
* - (work_order_no)
*
* 참고: 채번 "수정 가능"
*/
export interface RowNumberingConfig {
enabled: boolean; // 채번 사용 여부
targetColumn: string; // 채번 결과를 저장할 컬럼 (예: "shipment_plan_no")
// 채번 규칙 설정 (옵션설정 > 코드설정에서 등록된 채번 규칙)
numberingRuleId: string; // 채번 규칙 ID (numbering_rule 테이블)
}
/**
* 🆕 v3.12: 연동
*
*/
export interface SyncSaveConfig {
id: string; // 고유 ID
enabled: boolean; // 활성화 여부
// 소스 설정 (이 테이블에서)
sourceColumn: string; // 집계할 컬럼 (예: "plan_qty")
aggregationType: "sum" | "count" | "avg" | "min" | "max" | "latest"; // 집계 방식
// 대상 설정 (저장할 테이블)
targetTable: string; // 대상 테이블 (예: "sales_order_mng")
targetColumn: string; // 대상 컬럼 (예: "plan_ship_qty")
// 조인 키 (어떤 레코드를 업데이트할지)
joinKey: {
sourceField: string; // 이 테이블의 조인 키 (예: "sales_order_id")
targetField: string; // 대상 테이블의 키 (예: "id")
};
}
/**
@ -285,10 +329,19 @@ export interface AggregationConfig {
// - 산술 연산: +, -, *, /, ()
formula?: string; // 연산식 (예: "{total_balance} - SUM_EXT({plan_qty})")
// === 🆕 v3.11: SUM_EXT 참조 테이블 제한 ===
// SUM_EXT 함수가 참조할 외부 테이블 행 ID 목록
// 비어있거나 undefined면 모든 외부 테이블 데이터 사용 (기존 동작)
// 특정 테이블만 참조하려면 contentRow의 id를 배열로 지정
externalTableRefs?: string[]; // 참조할 테이블 행 ID 목록 (예: ["crow-1764571929625"])
// === 공통 ===
resultField: string; // 결과 필드명 (예: "total_balance_qty")
label: string; // 표시 라벨 (예: "총수주잔량")
// === 🆕 v3.10: 숨김 설정 ===
hidden?: boolean; // 레이아웃에서 숨김 (연산에만 사용, 기본: false)
// === 🆕 v3.9: 저장 설정 ===
saveConfig?: AggregationSaveConfig; // 연관 테이블 저장 설정
}
@ -337,6 +390,9 @@ export interface TableColumnConfig {
editable: boolean; // 편집 가능 여부
required?: boolean; // 필수 입력 여부
// 🆕 v3.13: 숨김 설정 (화면에는 안 보이지만 데이터는 존재)
hidden?: boolean; // 숨김 여부
// 🆕 v3.3: 컬럼 소스 테이블 지정 (조인 테이블 컬럼 사용 시)
fromTable?: string; // 컬럼이 속한 테이블 (비어있으면 소스 테이블)
fromJoinId?: string; // additionalJoins의 id 참조 (조인 테이블 컬럼일 때)

View File

@ -4064,6 +4064,35 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
);
}
// 📎 첨부파일 타입: 파일 아이콘과 개수 표시
if (inputType === "file" || inputType === "attachment" || column.columnName === "attachments") {
// JSONB 배열 또는 JSON 문자열 파싱
let files: any[] = [];
try {
if (typeof value === "string") {
files = JSON.parse(value);
} else if (Array.isArray(value)) {
files = value;
}
} catch {
// 파싱 실패 시 빈 배열
}
if (!files || files.length === 0) {
return <span className="text-muted-foreground text-xs">-</span>;
}
// 파일 개수와 아이콘 표시
const { Paperclip } = require("lucide-react");
return (
<div className="flex items-center gap-1 text-sm">
<Paperclip className="h-4 w-4 text-gray-500" />
<span className="text-blue-600 font-medium">{files.length}</span>
<span className="text-muted-foreground text-xs"></span>
</div>
);
}
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원)
if (inputType === "category") {
if (!value) return "";

View File

@ -2,7 +2,7 @@
import React, { useEffect, useState } from "react";
import { ComponentRendererProps } from "@/types/component";
import { AutoGenerationConfig } from "@/types/screen";
import { AutoGenerationConfig, AutoGenerationType } from "@/types/screen";
import { TextInputConfig } from "./types";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
@ -113,22 +113,20 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
// 채번 규칙은 비동기로 처리
if (testAutoGeneration.type === "numbering_rule") {
const ruleId = testAutoGeneration.options?.numberingRuleId;
if (ruleId) {
if (ruleId && ruleId !== "undefined" && ruleId !== "null") {
try {
console.log("🚀 채번 규칙 API 호출 시작:", ruleId);
const { generateNumberingCode } = await import("@/lib/api/numberingRule");
const response = await generateNumberingCode(ruleId);
console.log("✅ 채번 규칙 API 응답:", response);
const { previewNumberingCode } = await import("@/lib/api/numberingRule");
const response = await previewNumberingCode(ruleId);
if (response.success && response.data) {
generatedValue = response.data.generatedCode;
}
} catch (error) {
console.error("❌ 채번 규칙 코드 생성 실패:", error);
// 실패 시 조용히 무시 (채번 규칙이 없어도 화면은 정상 로드)
} catch {
// 네트워크 에러 등 예외 상황은 조용히 무시
} finally {
isGeneratingRef.current = false; // 생성 완료
isGeneratingRef.current = false;
}
} else {
console.warn("⚠️ 채번 규칙 ID가 없습니다");
isGeneratingRef.current = false;
}
} else {

View File

@ -5,6 +5,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Plus, Trash2 } from "lucide-react";
import { TextInputConfig } from "./types";
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";

View File

@ -224,6 +224,55 @@ export function UniversalFormModalComponent({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
// 🆕 beforeFormSave 이벤트 리스너 - ButtonPrimary 저장 시 formData를 전달
// 설정된 필드(columnName)만 병합하여 의도치 않은 덮어쓰기 방지
useEffect(() => {
const handleBeforeFormSave = (event: Event) => {
if (!(event instanceof CustomEvent) || !event.detail?.formData) return;
// 설정에 정의된 필드 columnName 목록 수집
const configuredFields = new Set<string>();
config.sections.forEach((section) => {
section.fields.forEach((field) => {
if (field.columnName) {
configuredFields.add(field.columnName);
}
});
});
console.log("[UniversalFormModal] beforeFormSave 이벤트 수신");
console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields));
// UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함)
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
// (UniversalFormModal이 해당 필드의 주인이므로)
for (const [key, value] of Object.entries(formData)) {
// 설정에 정의된 필드만 병합
if (configuredFields.has(key)) {
if (value !== undefined && value !== null && value !== "") {
event.detail.formData[key] = value;
console.log(`[UniversalFormModal] 필드 병합: ${key} =`, value);
}
}
}
// 반복 섹션 데이터도 병합 (필요한 경우)
if (Object.keys(repeatSections).length > 0) {
for (const [sectionId, items] of Object.entries(repeatSections)) {
const sectionKey = `_repeatSection_${sectionId}`;
event.detail.formData[sectionKey] = items;
console.log(`[UniversalFormModal] 반복 섹션 병합: ${sectionKey}`, items);
}
}
};
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
return () => {
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
};
}, [formData, repeatSections, config.sections]);
// 필드 레벨 linkedFieldGroup 데이터 로드
useEffect(() => {
const loadData = async () => {
@ -1134,7 +1183,7 @@ export function UniversalFormModalComponent({
}}
disabled={isDisabled}
>
<SelectTrigger id={fieldKey} className="w-full">
<SelectTrigger id={fieldKey} className="w-full" size="default">
<SelectValue placeholder={field.placeholder || "선택하세요"} />
</SelectTrigger>
<SelectContent>
@ -1513,47 +1562,37 @@ export function UniversalFormModalComponent({
{/* 섹션들 */}
<div className="space-y-4">{config.sections.map((section) => renderSection(section))}</div>
{/* 버튼 영역 */}
<div className="mt-6 flex justify-end gap-2 border-t pt-4">
{config.modal.showResetButton && (
{/* 버튼 영역 - 저장 버튼이 표시될 때만 렌더링 */}
{config.modal.showSaveButton !== false && (
<div className="mt-6 flex justify-end gap-2 border-t pt-4">
{config.modal.showResetButton && (
<Button
type="button"
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleReset();
}}
disabled={saving}
>
<RefreshCw className="mr-1 h-4 w-4" />
{config.modal.resetButtonText || "초기화"}
</Button>
)}
<Button
type="button"
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleReset();
handleSave();
}}
disabled={saving}
disabled={saving || !config.saveConfig.tableName}
>
<RefreshCw className="mr-1 h-4 w-4" />
{config.modal.resetButtonText || "초기화"}
{saving ? "저장 중..." : config.modal.saveButtonText || "저장"}
</Button>
)}
<Button
type="button"
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onCancel?.();
}}
disabled={saving}
>
{config.modal.cancelButtonText || "취소"}
</Button>
<Button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleSave();
}}
disabled={saving || !config.saveConfig.tableName}
>
{saving ? "저장 중..." : config.modal.saveButtonText || "저장"}
</Button>
</div>
</div>
)}
{/* 삭제 확인 다이얼로그 */}
<AlertDialog
@ -1606,7 +1645,7 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa
return (
<Select value={value || ""} onValueChange={onChange} disabled={disabled || loading}>
<SelectTrigger>
<SelectTrigger size="default">
<SelectValue placeholder={loading ? "로딩 중..." : placeholder} />
</SelectTrigger>
<SelectContent>

View File

@ -402,6 +402,17 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
</Select>
</div>
<div className="border rounded-md p-2 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium"> </span>
<Switch
checked={config.modal.showSaveButton !== false}
onCheckedChange={(checked) => updateModalConfig({ showSaveButton: checked })}
/>
</div>
<HelpText>ButtonPrimary </HelpText>
{config.modal.showSaveButton !== false && (
<div>
<Label className="text-[10px]"> </Label>
<Input
@ -410,13 +421,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
className="h-7 text-xs mt-1"
/>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Input
value={config.modal.cancelButtonText || "취소"}
onChange={(e) => updateModalConfig({ cancelButtonText: e.target.value })}
className="h-7 text-xs mt-1"
/>
)}
</div>
</AccordionContent>
</AccordionItem>
@ -1896,7 +1901,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
<div className="space-y-2 pt-2 border-t">
<HelpText> 참조: DB .</HelpText>
<div>
<Label className="text-[10px]"> ( )</Label>
<Label className="text-[10px]"> </Label>
<Select
value={selectedField.selectOptions?.tableName || ""}
onValueChange={(value) =>
@ -1908,7 +1913,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
})
}
>
<SelectTrigger className="h-6 text-[10px] mt-1">
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
@ -1919,10 +1924,10 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
))}
</SelectContent>
</Select>
<HelpText>: dept_info ( )</HelpText>
<HelpText> </HelpText>
</div>
<div>
<Label className="text-[10px]"> ( )</Label>
<Label className="text-[10px]"> </Label>
<Input
value={selectedField.selectOptions?.valueColumn || ""}
onChange={(e) =>
@ -1933,13 +1938,17 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
},
})
}
placeholder="dept_code"
className="h-6 text-[10px] mt-1"
placeholder="customer_code"
className="h-7 text-xs mt-1"
/>
<HelpText> (: D001)</HelpText>
<HelpText>
<br />
: customer_code, customer_id
</HelpText>
</div>
<div>
<Label className="text-[10px]"> ( )</Label>
<Label className="text-[10px]"> </Label>
<Input
value={selectedField.selectOptions?.labelColumn || ""}
onChange={(e) =>
@ -1950,10 +1959,14 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
},
})
}
placeholder="dept_name"
className="h-6 text-[10px] mt-1"
placeholder="customer_name"
className="h-7 text-xs mt-1"
/>
<HelpText> (: 영업부)</HelpText>
<HelpText>
<br />
: customer_name, company_name
</HelpText>
</div>
</div>
)}

View File

@ -12,6 +12,7 @@ export const defaultConfig: UniversalFormModalConfig = {
size: "lg",
closeOnOutsideClick: false,
showCloseButton: true,
showSaveButton: true,
saveButtonText: "저장",
cancelButtonText: "취소",
showResetButton: false,

View File

@ -2651,6 +2651,55 @@ export class ButtonActionExecutor {
controlDataSource,
};
// 노드 플로우 방식 실행 (flowConfig가 있는 경우)
const hasFlowConfig = config.dataflowConfig?.flowConfig && config.dataflowConfig.flowConfig.flowId;
if (hasFlowConfig) {
console.log("🎯 저장 후 노드 플로우 실행:", config.dataflowConfig.flowConfig);
const { flowId } = config.dataflowConfig.flowConfig;
try {
// 노드 플로우 실행 API 호출
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
// 데이터 소스 준비
let sourceData: any = context.formData || {};
// repeat-screen-modal 데이터가 있으면 병합
const repeatScreenModalKeys = Object.keys(context.formData || {}).filter((key) =>
key.startsWith("_repeatScreenModal_"),
);
if (repeatScreenModalKeys.length > 0) {
console.log("📦 repeat-screen-modal 데이터 발견:", repeatScreenModalKeys);
}
console.log("📦 노드 플로우에 전달할 데이터:", {
flowId,
dataSourceType: controlDataSource,
sourceData,
});
const result = await executeNodeFlow(flowId, {
dataSourceType: controlDataSource,
sourceData,
context: extendedContext,
});
if (result.success) {
console.log("✅ 저장 후 노드 플로우 실행 완료:", result);
toast.success("제어 로직 실행이 완료되었습니다.");
} else {
console.error("❌ 저장 후 노드 플로우 실행 실패:", result);
toast.error("저장은 완료되었으나 제어 실행 중 오류가 발생했습니다.");
}
} catch (error: any) {
console.error("❌ 저장 후 노드 플로우 실행 오류:", error);
toast.error(`제어 실행 오류: ${error.message || "알 수 없는 오류"}`);
}
return; // 노드 플로우 실행 후 종료
}
// 관계 기반 제어 실행
if (config.dataflowConfig?.controlMode === "relationship" && config.dataflowConfig?.relationshipConfig) {
console.log("🔗 저장 후 관계 기반 제어 실행:", config.dataflowConfig.relationshipConfig);

View File

@ -34,10 +34,11 @@
"@react-three/fiber": "^9.4.0",
"@tanstack/react-query": "^5.86.0",
"@tanstack/react-table": "^8.21.3",
"@tiptap/extension-placeholder": "^2.11.5",
"@tiptap/core": "^3.13.0",
"@tiptap/extension-placeholder": "^2.27.1",
"@tiptap/pm": "^2.11.5",
"@tiptap/react": "^2.11.5",
"@tiptap/starter-kit": "^2.11.5",
"@tiptap/react": "^2.27.1",
"@tiptap/starter-kit": "^2.27.1",
"@turf/buffer": "^7.2.0",
"@turf/helpers": "^7.2.0",
"@turf/intersect": "^7.2.0",
@ -3301,16 +3302,16 @@
}
},
"node_modules/@tiptap/core": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.13.0.tgz",
"integrity": "sha512-iUelgiTMgPVMpY5ZqASUpk8mC8HuR9FWKaDzK27w9oWip9tuB54Z8mePTxNcQaSPb6ErzEaC8x8egrRt7OsdGQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^2.7.0"
"@tiptap/pm": "^3.13.0"
}
},
"node_modules/@tiptap/extension-blockquote": {
@ -3699,6 +3700,19 @@
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/starter-kit/node_modules/@tiptap/core": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@turf/along": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@turf/along/-/along-7.2.0.tgz",
@ -6070,7 +6084,7 @@
"version": "20.19.24",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz",
"integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@ -6108,7 +6122,7 @@
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz",
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@ -12524,13 +12538,6 @@
"react-dom": ">=16"
}
},
"node_modules/react-is": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
"integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==",
"license": "MIT",
"peer": true
},
"node_modules/react-leaflet": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
@ -14190,7 +14197,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {

View File

@ -16,10 +16,6 @@
"test:dataflow": "jest lib/services/__tests__/buttonDataflowPerformance.test.ts"
},
"dependencies": {
"@tiptap/extension-placeholder": "^2.11.5",
"@tiptap/pm": "^2.11.5",
"@tiptap/react": "^2.11.5",
"@tiptap/starter-kit": "^2.11.5",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@ -46,6 +42,11 @@
"@react-three/fiber": "^9.4.0",
"@tanstack/react-query": "^5.86.0",
"@tanstack/react-table": "^8.21.3",
"@tiptap/core": "^3.13.0",
"@tiptap/extension-placeholder": "^2.27.1",
"@tiptap/pm": "^2.11.5",
"@tiptap/react": "^2.27.1",
"@tiptap/starter-kit": "^2.27.1",
"@turf/buffer": "^7.2.0",
"@turf/helpers": "^7.2.0",
"@turf/intersect": "^7.2.0",