Merge conflict resolved: V2Media.tsx

원격 버전(7ec5a43) 채택 - 완전한 인라인 UI 코드 사용
This commit is contained in:
DDD1542 2026-02-05 10:20:22 +09:00
commit 1de67a88b5
28 changed files with 1326 additions and 854 deletions

View File

@ -1044,6 +1044,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@ -2371,6 +2372,7 @@
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT",
"peer": true,
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
@ -3474,6 +3476,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@ -3710,6 +3713,7 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@ -3927,6 +3931,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -4453,6 +4458,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
@ -5663,6 +5669,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@ -7425,6 +7432,7 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "^29.7.0",
"@jest/types": "^29.6.3",
@ -8394,7 +8402,6 @@
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
@ -9283,6 +9290,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
@ -10133,7 +10141,6 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
}
@ -10942,6 +10949,7 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@ -11047,6 +11055,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@ -225,12 +225,12 @@ router.post("/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequ
router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
const { formData } = req.body; // 폼 데이터 (날짜 컬럼 기준 생성 시 사용)
const { formData, userInputCode } = req.body; // 폼 데이터 + 사용자가 편집한 코드
logger.info("코드 할당 요청", { ruleId, companyCode, hasFormData: !!formData });
logger.info("코드 할당 요청", { ruleId, companyCode, hasFormData: !!formData, userInputCode });
try {
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode, formData);
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode, formData, userInputCode);
logger.info("코드 할당 성공", { ruleId, allocatedCode });
return res.json({ success: true, data: { generatedCode: allocatedCode } });
} catch (error: any) {

View File

@ -886,8 +886,9 @@ class NumberingRuleService {
.sort((a: any, b: any) => a.order - b.order)
.map((part: any) => {
if (part.generationMethod === "manual") {
// 수동 입력 - 플레이스홀더 표시 (실제 값은 사용자가 입력)
return part.manualConfig?.placeholder || "____";
// 수동 입력 - 항상 ____ 마커 사용 (프론트엔드에서 편집 가능하게 처리)
// placeholder 텍스트는 프론트엔드에서 별도로 표시
return "____";
}
const autoConfig = part.autoConfig || {};
@ -1014,11 +1015,13 @@ class NumberingRuleService {
* @param ruleId ID
* @param companyCode
* @param formData ( )
* @param userInputCode ( )
*/
async allocateCode(
ruleId: string,
companyCode: string,
formData?: Record<string, any>
formData?: Record<string, any>,
userInputCode?: string
): Promise<string> {
const pool = getPool();
const client = await pool.connect();
@ -1029,11 +1032,77 @@ class NumberingRuleService {
const rule = await this.getRuleById(ruleId, companyCode);
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
// 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출
const manualParts = rule.parts.filter((p: any) => p.generationMethod === "manual");
let extractedManualValues: string[] = [];
if (manualParts.length > 0 && userInputCode) {
// 프리뷰 코드를 생성해서 ____ 위치 파악
const previewParts = rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map((part: any) => {
if (part.generationMethod === "manual") {
return "____";
}
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
return "X".repeat(length); // 순번 자리 표시
}
case "text":
return autoConfig.textValue || "";
case "date":
return "DATEPART"; // 날짜 자리 표시
default:
return "";
}
});
const separator = rule.separator || "";
const previewTemplate = previewParts.join(separator);
// 사용자 입력 코드에서 수동 입력 부분 추출
// 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출
const templateParts = previewTemplate.split("____");
if (templateParts.length > 1) {
let remainingCode = userInputCode;
for (let i = 0; i < templateParts.length - 1; i++) {
const prefix = templateParts[i];
const suffix = templateParts[i + 1];
// prefix 이후 부분 추출
if (prefix && remainingCode.startsWith(prefix)) {
remainingCode = remainingCode.slice(prefix.length);
}
// suffix 이전까지가 수동 입력 값
if (suffix) {
// suffix에서 순번(XXX)이나 날짜 부분을 제외한 실제 구분자 찾기
const suffixStart = suffix.replace(/X+|DATEPART/g, "");
const manualEndIndex = suffixStart ? remainingCode.indexOf(suffixStart) : remainingCode.length;
if (manualEndIndex > 0) {
extractedManualValues.push(remainingCode.slice(0, manualEndIndex));
remainingCode = remainingCode.slice(manualEndIndex);
}
} else {
extractedManualValues.push(remainingCode);
}
}
}
logger.info(`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedManualValues)}`);
}
let manualPartIndex = 0;
const parts = rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map((part: any) => {
if (part.generationMethod === "manual") {
return part.manualConfig?.value || "";
// 추출된 수동 입력 값 사용, 없으면 기본값 사용
const manualValue = extractedManualValues[manualPartIndex] || part.manualConfig?.value || "";
manualPartIndex++;
return manualValue;
}
const autoConfig = part.autoConfig || {};

View File

@ -1461,6 +1461,40 @@ export class TableManagementService {
});
}
// 🔧 파이프로 구분된 문자열 처리 (객체에서 추출한 actualValue도 처리)
if (typeof actualValue === "string" && actualValue.includes("|")) {
const columnInfo = await this.getColumnWebTypeInfo(
tableName,
columnName
);
// 날짜 타입이면 날짜 범위로 처리
if (
columnInfo &&
(columnInfo.webType === "date" || columnInfo.webType === "datetime")
) {
return this.buildDateRangeCondition(columnName, actualValue, paramIndex);
}
// 그 외 타입이면 다중선택(IN 조건)으로 처리
const multiValues = actualValue
.split("|")
.filter((v: string) => v.trim() !== "");
if (multiValues.length > 0) {
const placeholders = multiValues
.map((_: string, idx: number) => `$${paramIndex + idx}`)
.join(", ");
logger.info(
`🔍 다중선택 필터 적용 (객체에서 추출): ${columnName} IN (${multiValues.join(", ")})`
);
return {
whereClause: `${columnName}::text IN (${placeholders})`,
values: multiValues,
paramCount: multiValues.length,
};
}
}
// "__ALL__" 값이거나 빈 값이면 필터 조건을 적용하지 않음
if (
actualValue === "__ALL__" ||
@ -3369,14 +3403,16 @@ export class TableManagementService {
if (options.search) {
for (const [key, value] of Object.entries(options.search)) {
// 검색값 추출 (객체 형태일 수 있음)
// 검색값 및 operator 추출 (객체 형태일 수 있음)
let searchValue = value;
let operator = "contains"; // 기본값: 부분 일치
if (
typeof value === "object" &&
value !== null &&
"value" in value
) {
searchValue = value.value;
operator = (value as any).operator || "contains";
}
// 빈 값이면 스킵
@ -3428,15 +3464,49 @@ export class TableManagementService {
// 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색
const aliasKey = `${joinConfig.referenceTable}:${joinConfig.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
whereConditions.push(
`${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'`
);
entitySearchColumns.push(
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
);
logger.info(
`🎯 Entity 조인 검색: ${key}${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${safeValue}%' (별칭: ${alias})`
);
// 🔧 파이프로 구분된 다중 선택값 처리
if (safeValue.includes("|")) {
const multiValues = safeValue
.split("|")
.filter((v: string) => v.trim() !== "");
if (multiValues.length > 0) {
const inClause = multiValues
.map((v: string) => `'${v}'`)
.join(", ");
whereConditions.push(
`${alias}.${joinConfig.displayColumn}::text IN (${inClause})`
);
entitySearchColumns.push(
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
);
logger.info(
`🎯 Entity 조인 다중선택 검색: ${key}${joinConfig.referenceTable}.${joinConfig.displayColumn} IN (${multiValues.join(", ")}) (별칭: ${alias})`
);
}
} else if (operator === "equals") {
// 🔧 equals 연산자: 정확히 일치
whereConditions.push(
`${alias}.${joinConfig.displayColumn}::text = '${safeValue}'`
);
entitySearchColumns.push(
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
);
logger.info(
`🎯 Entity 조인 정확히 일치 검색: ${key}${joinConfig.referenceTable}.${joinConfig.displayColumn} = '${safeValue}' (별칭: ${alias})`
);
} else {
// 기본: 부분 일치 (ILIKE)
whereConditions.push(
`${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'`
);
entitySearchColumns.push(
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
);
logger.info(
`🎯 Entity 조인 검색: ${key}${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${safeValue}%' (별칭: ${alias})`
);
}
} else if (key === "writer_dept_code") {
// writer_dept_code: user_info.dept_code에서 검색
const userAliasKey = Array.from(aliasMap.keys()).find((k) =>
@ -3473,10 +3543,33 @@ export class TableManagementService {
}
} else {
// 일반 컬럼인 경우: 메인 테이블에서 검색
whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`);
logger.info(
`🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'`
);
// 🔧 파이프로 구분된 다중 선택값 처리
if (safeValue.includes("|")) {
const multiValues = safeValue
.split("|")
.filter((v: string) => v.trim() !== "");
if (multiValues.length > 0) {
const inClause = multiValues
.map((v: string) => `'${v}'`)
.join(", ");
whereConditions.push(`main.${key}::text IN (${inClause})`);
logger.info(
`🔍 다중선택 컬럼 검색: ${key} → main.${key} IN (${multiValues.join(", ")})`
);
}
} else if (operator === "equals") {
// 🔧 equals 연산자: 정확히 일치
whereConditions.push(`main.${key}::text = '${safeValue}'`);
logger.info(
`🔍 정확히 일치 검색: ${key} → main.${key} = '${safeValue}'`
);
} else {
// 기본: 부분 일치 (ILIKE)
whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`);
logger.info(
`🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'`
);
}
}
}
}

View File

@ -12,6 +12,13 @@ services:
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8080/api
- WATCHPACK_POLLING=true
- NODE_OPTIONS=--max-old-space-size=4096
deploy:
resources:
limits:
memory: 6G
reservations:
memory: 2G
volumes:
- ./frontend:/app
- /app/node_modules

View File

@ -239,7 +239,8 @@ function ScreenViewPage() {
compType?.includes("textarea") ||
compType?.includes("v2-input") ||
compType?.includes("v2-select") ||
compType?.includes("v2-media"); // 🆕 미디어 컴포넌트 추가
compType?.includes("v2-media") ||
compType?.includes("file-upload"); // 🆕 레거시 파일 업로드 포함
const hasColumnName = !!(comp as any).columnName;
return isInputType && hasColumnName;
});

View File

@ -127,6 +127,24 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 모달이 열린 시간 추적 (저장 성공 이벤트 무시용)
const modalOpenedAtRef = React.useRef<number>(0);
// 🆕 채번 필드 수동 입력 값 변경 이벤트 리스너
useEffect(() => {
const handleNumberingValueChanged = (event: CustomEvent) => {
const { columnName, value } = event.detail;
if (columnName && modalState.isOpen) {
setFormData((prev) => ({
...prev,
[columnName]: value,
}));
}
};
window.addEventListener("numberingValueChanged", handleNumberingValueChanged as EventListener);
return () => {
window.removeEventListener("numberingValueChanged", handleNumberingValueChanged as EventListener);
};
}, [modalState.isOpen]);
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleOpenModal = (event: CustomEvent) => {
@ -140,6 +158,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
splitPanelParentData,
selectedData: eventSelectedData,
selectedIds,
isCreateMode, // 🆕 복사 모드 플래그 (true면 editData가 있어도 originalData 설정 안 함)
} = event.detail;
// 🆕 모달 열린 시간 기록
@ -163,7 +182,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
}
// 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드)
if (editData) {
// 🔧 단, isCreateMode가 true이면 (복사 모드) originalData를 설정하지 않음 → 채번 생성 가능
if (editData && !isCreateMode) {
// 🆕 배열인 경우 두 가지 데이터를 설정:
// 1. formData: 첫 번째 요소(객체) - 일반 입력 필드용 (TextInput 등)
// 2. selectedData: 전체 배열 - 다중 항목 컴포넌트용 (SelectedItemsDetailInput 등)
@ -177,6 +197,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
setSelectedData([editData]); // 🔧 단일 객체도 배열로 변환하여 저장
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
}
} else if (editData && isCreateMode) {
// 🆕 복사 모드: formData만 설정하고 originalData는 null로 유지 (채번 생성 가능)
if (Array.isArray(editData)) {
const firstRecord = editData[0] || {};
setFormData(firstRecord);
setSelectedData(editData);
} else {
setFormData(editData);
setSelectedData([editData]);
}
setOriginalData(null); // 🔧 복사 모드에서는 originalData를 null로 설정
} else {
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함

View File

@ -777,12 +777,14 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
try {
console.log(`🔄 [EditModal] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`);
const allocateResult = await allocateNumberingCode(ruleId);
// 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const userInputCode = dataToSave[fieldName] as string;
console.log(`🔄 [EditModal] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}, 사용자입력: ${userInputCode}`);
const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData);
if (allocateResult.success && allocateResult.data?.generatedCode) {
const newCode = allocateResult.data.generatedCode;
console.log(`✅ [EditModal] ${fieldName} 새 코드 할당: ${dataToSave[fieldName]}${newCode}`);
console.log(`✅ [EditModal] ${fieldName} 새 코드 할당: ${userInputCode}${newCode}`);
dataToSave[fieldName] = newCode;
} else {
console.warn(`⚠️ [EditModal] ${fieldName} 코드 할당 실패:`, allocateResult.error);

View File

@ -562,13 +562,18 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
try {
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
// 단, v2-media 컴포넌트의 파일 배열(objid 배열)은 포함
// 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함
const masterFormData: Record<string, any> = {};
// v2-media 컴포넌트의 columnName 목록 수집
// 파일 업로드 컴포넌트의 columnName 목록 수집 (v2-media, file-upload 모두 포함)
const mediaColumnNames = new Set(
allComponents
.filter((c: any) => c.componentType === "v2-media" || c.url?.includes("v2-media"))
.filter((c: any) =>
c.componentType === "v2-media" ||
c.componentType === "file-upload" ||
c.url?.includes("v2-media") ||
c.url?.includes("file-upload")
)
.map((c: any) => c.columnName || c.componentConfig?.columnName)
.filter(Boolean)
);

View File

@ -834,8 +834,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
{/* 이벤트 버스 */}
<SelectItem value="event"> </SelectItem>
{/* 🔒 - , UI
{/* 복사 */}
<SelectItem value="copy"> ( )</SelectItem>
{/* 🔒 - , UI
<SelectItem value="openRelatedModal"> </SelectItem>
<SelectItem value="openModalWithData">(deprecated) + </SelectItem>
<SelectItem value="view_table_history"> </SelectItem>

View File

@ -80,7 +80,7 @@ export function ComponentsPanel({
"textarea-basic",
// V2 컴포넌트로 대체됨
"image-widget", // → V2Media (image)
"file-upload", // → V2Media (file)
// "file-upload", // 🆕 레거시 컴포넌트 노출 (안정적인 파일 업로드)
"entity-search-input", // → V2Select (entity 모드)
"autocomplete-search-input", // → V2Select (autocomplete 모드)
// DataFlow 전용 (일반 화면에서 불필요)

View File

@ -700,9 +700,10 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
);
// 🆕 채번 API 호출 (비동기)
const generateNumberingCode = useCallback(async (ruleId: string): Promise<string> => {
// 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가
const generateNumberingCode = useCallback(async (ruleId: string, userInputCode?: string, formData?: Record<string, any>): Promise<string> => {
try {
const result = await allocateNumberingCode(ruleId);
const result = await allocateNumberingCode(ruleId, userInputCode, formData);
if (result.success && result.data?.generatedCode) {
return result.data.generatedCode;
}
@ -831,7 +832,8 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
if (match) {
const ruleId = match[1];
try {
const result = await allocateNumberingCode(ruleId);
// 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const result = await allocateNumberingCode(ruleId, undefined, newRow);
if (result.success && result.data?.generatedCode) {
newRow[key] = result.data.generatedCode;
} else {

View File

@ -625,6 +625,40 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName, columnName, isEditMode, categoryValuesForNumbering]);
// 🆕 beforeFormSave 이벤트 리스너 - 저장 직전에 현재 조합된 값을 formData에 주입
useEffect(() => {
const inputType = propsInputType || config.inputType || config.type || "text";
if (inputType !== "numbering" || !columnName) return;
const handleBeforeFormSave = (event: CustomEvent) => {
const template = numberingTemplateRef.current;
if (!template || !template.includes("____")) return;
// 템플릿에서 prefix와 suffix 추출
const templateParts = template.split("____");
const templatePrefix = templateParts[0] || "";
const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : "";
// 현재 조합된 값 생성
const currentValue = templatePrefix + manualInputValue + templateSuffix;
// formData에 직접 주입
if (event.detail?.formData && columnName) {
event.detail.formData[columnName] = currentValue;
console.log("🔧 [V2Input] beforeFormSave에서 채번 값 주입:", {
columnName,
manualInputValue,
currentValue,
});
}
};
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
return () => {
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
};
}, [columnName, manualInputValue, propsInputType, config.inputType, config.type]);
// 실제 표시할 값 (자동생성 값 또는 props value)
const displayValue = autoGeneratedValue ?? value;
@ -769,7 +803,19 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
const newValue = templatePrefix + newUserInput + templateSuffix;
userEditedNumberingRef.current = true;
setAutoGeneratedValue(newValue);
// 모든 방법으로 formData 업데이트 시도
onChange?.(newValue);
if (onFormDataChange && columnName) {
onFormDataChange(columnName, newValue);
}
// 커스텀 이벤트로도 전달 (최후의 보루)
if (typeof window !== "undefined" && columnName) {
window.dispatchEvent(new CustomEvent("numberingValueChanged", {
detail: { columnName, value: newValue }
}));
}
}}
placeholder="입력"
className="h-full min-w-[60px] flex-1 bg-transparent px-2 text-sm focus-visible:outline-none"

File diff suppressed because it is too large Load Diff

View File

@ -567,9 +567,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
);
// 🆕 채번 API 호출 (비동기)
const generateNumberingCode = useCallback(async (ruleId: string): Promise<string> => {
// 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가
const generateNumberingCode = useCallback(async (ruleId: string, userInputCode?: string, formData?: Record<string, any>): Promise<string> => {
try {
const result = await allocateNumberingCode(ruleId);
const result = await allocateNumberingCode(ruleId, userInputCode, formData);
if (result.success && result.data?.generatedCode) {
return result.data.generatedCode;
}
@ -690,7 +691,8 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
if (match) {
const ruleId = match[1];
try {
const result = await allocateNumberingCode(ruleId);
// 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const result = await allocateNumberingCode(ruleId, undefined, newRow);
if (result.success && result.data?.generatedCode) {
newRow[key] = result.data.generatedCode;
} else {

View File

@ -139,12 +139,20 @@ export async function previewNumberingCode(
/**
* ( )
*
* @param ruleId ID
* @param userInputCode ( )
* @param formData (/ )
*/
export async function allocateNumberingCode(
ruleId: string
ruleId: string,
userInputCode?: string,
formData?: Record<string, any>
): Promise<ApiResponse<{ generatedCode: string }>> {
try {
const response = await apiClient.post(`/numbering-rules/${ruleId}/allocate`);
const response = await apiClient.post(`/numbering-rules/${ruleId}/allocate`, {
userInputCode,
formData,
});
return response.data;
} catch (error: any) {
return { success: false, error: error.message || "코드 할당 실패" };

View File

@ -427,9 +427,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 컴포넌트의 columnName에 해당하는 formData 값 추출
const fieldName = (component as any).columnName || (component as any).componentConfig?.columnName || component.id;
// 🔍 V2Media 디버깅
if (componentType === "v2-media") {
console.log("[DynamicComponentRenderer] v2-media:", {
// 🔍 파일 업로드 컴포넌트 디버깅
if (componentType === "v2-media" || componentType === "file-upload") {
console.log("[DynamicComponentRenderer] 파일 업로드:", {
componentType,
componentId: component.id,
columnName: (component as any).columnName,
configColumnName: (component as any).componentConfig?.columnName,

View File

@ -856,8 +856,10 @@ export function RepeatScreenModalComponent({
});
// 채번 API 호출 (allocate: 실제 시퀀스 증가)
// 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
const response = await allocateNumberingCode(rowNumbering.numberingRuleId);
const userInputCode = newRowData[rowNumbering.targetColumn] as string;
const response = await allocateNumberingCode(rowNumbering.numberingRuleId, userInputCode, newRowData);
if (response.success && response.data) {
newRowData[rowNumbering.targetColumn] = response.data.generatedCode;

View File

@ -1443,8 +1443,9 @@ export function UniversalFormModalComponent({
if (isNewRecord || hasNoValue) {
try {
// allocateNumberingCode로 실제 순번 증가
const response = await allocateNumberingCode(field.numberingRule.ruleId);
// 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const userInputCode = mainData[field.columnName] as string;
const response = await allocateNumberingCode(field.numberingRule.ruleId, userInputCode, mainData);
if (response.success && response.data?.generatedCode) {
mainData[field.columnName] = response.data.generatedCode;
}

View File

@ -1325,7 +1325,31 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
...userStyle,
};
const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼";
// 버튼 텍스트 결정 (다양한 소스에서 가져옴)
// "기본 버튼"은 컴포넌트 생성 시 기본값이므로 무시
const labelValue = component.label === "기본 버튼" ? undefined : component.label;
// 액션 타입에 따른 기본 텍스트 (modal 액션과 동일하게)
const actionType = processedConfig.action?.type || component.componentConfig?.action?.type;
const actionDefaultText: Record<string, string> = {
save: "저장",
delete: "삭제",
modal: "등록",
edit: "수정",
copy: "복사",
close: "닫기",
cancel: "취소",
};
const buttonContent =
processedConfig.text ||
component.webTypeConfig?.text ||
component.componentConfig?.text ||
component.config?.text ||
component.style?.labelText ||
labelValue ||
actionDefaultText[actionType as string] ||
"버튼";
return (
<>

View File

@ -25,8 +25,20 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
// 값 변경 핸들러
const handleChange = (value: any) => {
console.log("🔄 [V2InputRenderer] handleChange 호출:", {
columnName,
value,
isInteractive,
hasOnFormDataChange: !!onFormDataChange,
});
if (isInteractive && onFormDataChange && columnName) {
onFormDataChange(columnName, value);
} else {
console.warn("⚠️ [V2InputRenderer] onFormDataChange 호출 스킵:", {
isInteractive,
hasOnFormDataChange: !!onFormDataChange,
columnName,
});
}
};

View File

@ -3,90 +3,86 @@
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2MediaDefinition } from "./index";
import { V2Media } from "@/components/v2/V2Media";
import FileUploadComponent from "../file-upload/FileUploadComponent";
/**
* V2Media
* , , ,
* FileUploadComponent를
*
*/
export class V2MediaRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2MediaDefinition;
render(): React.ReactElement {
const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props;
const {
component,
formData,
onFormDataChange,
isDesignMode,
isSelected,
isInteractive,
onUpdate,
...restProps
} = this.props;
// 컴포넌트 설정 추출
const config = component.componentConfig || component.config || {};
const columnName = component.columnName;
const tableName = component.tableName || this.props.tableName;
// formData에서 현재 값 가져오기
const rawValue = formData?.[columnName] ?? component.value ?? "";
// objid를 미리보기 URL로 변환하는 함수 (number/string 모두 처리)
const convertToPreviewUrl = (val: any): string => {
if (val === null || val === undefined || val === "") return "";
// number면 string으로 변환
const strVal = String(val);
// 이미 URL 형태면 그대로 반환
if (strVal.startsWith("/") || strVal.startsWith("http")) return strVal;
// 숫자로만 이루어진 문자열이면 objid로 간주하고 미리보기 URL 생성
if (/^\d+$/.test(strVal)) {
return `/api/files/preview/${strVal}`;
}
return strVal;
};
// 배열 또는 단일 값 처리
const currentValue = Array.isArray(rawValue)
? rawValue.map(convertToPreviewUrl)
: convertToPreviewUrl(rawValue);
console.log("[V2Media] rawValue:", rawValue, "-> currentValue:", currentValue);
// 값 변경 핸들러
const handleChange = (value: any) => {
if (isInteractive && onFormDataChange && columnName) {
onFormDataChange(columnName, value);
}
};
// V1 file-upload, image-widget에서 넘어온 설정 매핑
// V1 file-upload에서 사용하는 형태로 설정 매핑
const mediaType = config.mediaType || config.type || this.getMediaTypeFromWebType(component.webType);
// maxSize: MB → bytes 변환 (V1은 bytes, V2는 MB 단위 사용)
// maxSize: MB → bytes 변환
const maxSizeBytes = config.maxSize
? (config.maxSize > 1000 ? config.maxSize : config.maxSize * 1024 * 1024)
: 10 * 1024 * 1024; // 기본 10MB
// 레거시 컴포넌트 설정 형태로 변환
const legacyComponentConfig = {
maxFileCount: config.multiple ? 10 : 1,
maxFileSize: maxSizeBytes,
accept: config.accept || this.getDefaultAccept(mediaType),
docType: config.docType || "DOCUMENT",
docTypeName: config.docTypeName || "일반 문서",
showFileList: config.showFileList ?? true,
dragDrop: config.dragDrop ?? true,
};
// 레거시 컴포넌트 형태로 변환
const legacyComponent = {
...component,
id: component.id,
columnName: columnName,
tableName: tableName,
componentConfig: legacyComponentConfig,
};
// onFormDataChange 래퍼: 레거시 컴포넌트는 객체를 전달하므로 변환 필요
const handleFormDataChange = (data: any) => {
if (onFormDataChange) {
// 레거시 컴포넌트는 { [columnName]: value } 형태로 전달
// 부모는 (fieldName, value) 형태를 기대
Object.entries(data).forEach(([key, value]) => {
// __attachmentsUpdate 같은 메타 데이터는 건너뛰기
if (!key.startsWith("__")) {
onFormDataChange(key, value);
}
});
}
};
return (
<V2Media
id={component.id}
label={component.label}
required={component.required}
readonly={config.readonly || component.readonly}
disabled={config.disabled || component.disabled}
value={currentValue}
onChange={handleChange}
config={{
type: mediaType,
multiple: config.multiple ?? false,
preview: config.preview ?? true,
maxSize: maxSizeBytes,
accept: config.accept || this.getDefaultAccept(mediaType),
uploadEndpoint: config.uploadEndpoint || "/files/upload",
}}
style={component.style}
size={component.size}
formData={formData}
columnName={columnName}
tableName={tableName}
{...restProps}
<FileUploadComponent
component={legacyComponent}
componentConfig={legacyComponentConfig}
componentStyle={component.style || {}}
className=""
isInteractive={isInteractive ?? true}
isDesignMode={isDesignMode ?? false}
formData={formData || {}}
onFormDataChange={handleFormDataChange}
onUpdate={onUpdate}
/>
);
}

View File

@ -459,6 +459,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함
const [filterGroups, setFilterGroups] = useState<FilterGroup[]>([]);
// 🆕 joinColumnMapping - filteredData에서 사용하므로 먼저 정의해야 함
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
// 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터
const filteredData = useMemo(() => {
@ -473,14 +476,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
}
// 2. 헤더 필터 적용 (joinColumnMapping 사용 안 함 - 직접 컬럼명 사용)
// 2. 헤더 필터 적용 (joinColumnMapping 사용 - 조인된 컬럼과 일치해야 함)
if (Object.keys(headerFilters).length > 0) {
result = result.filter((row) => {
return Object.entries(headerFilters).every(([columnName, values]) => {
if (values.size === 0) return true;
// 여러 가능한 컬럼명 시도
const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()];
// joinColumnMapping을 사용하여 조인된 컬럼명 확인
const mappedColumnName = joinColumnMapping[columnName] || columnName;
// 여러 가능한 컬럼명 시도 (mappedColumnName 우선)
const cellValue = row[mappedColumnName] ?? row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()];
const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : "";
return values.has(cellStr);
@ -541,7 +547,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
return result;
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups]);
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups, joinColumnMapping]);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
@ -554,7 +560,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [tableLabel, setTableLabel] = useState<string>("");
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
const [columnMeta, setColumnMeta] = useState<
Record<string, { webType?: string; codeCategory?: string; inputType?: string }>
>({});

View File

@ -475,9 +475,21 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
filterValue = filterValue.join("|");
}
// 🔧 filterType에 따라 operator 설정
// - "select" 유형: 정확히 일치 (equals)
// - "text" 유형: 부분 일치 (contains)
// - "date", "number": 각각 적절한 처리
let operator = "contains"; // 기본값
if (filter.filterType === "select") {
operator = "equals"; // 선택 필터는 정확히 일치
} else if (filter.filterType === "number") {
operator = "equals"; // 숫자도 정확히 일치
}
return {
...filter,
value: filterValue || "",
operator, // operator 추가
};
})
.filter((f) => {

View File

@ -737,7 +737,9 @@ export class ButtonActionExecutor {
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumberingRepeater)) {
try {
const allocateResult = await allocateNumberingCode(ruleId);
// 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const userInputCode = context.formData[fieldName] as string;
const allocateResult = await allocateNumberingCode(ruleId, userInputCode, context.formData);
if (allocateResult.success && allocateResult.data?.generatedCode) {
const newCode = allocateResult.data.generatedCode;
@ -1030,7 +1032,9 @@ export class ButtonActionExecutor {
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
try {
const allocateResult = await allocateNumberingCode(ruleId);
// 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const userInputCode = formData[fieldName] as string;
const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData);
if (allocateResult.success && allocateResult.data?.generatedCode) {
const newCode = allocateResult.data.generatedCode;
@ -2063,7 +2067,9 @@ export class ButtonActionExecutor {
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
try {
const allocateResult = await allocateNumberingCode(ruleId);
// 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const userInputCode = commonFieldsData[fieldName] as string;
const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData);
if (allocateResult.success && allocateResult.data?.generatedCode) {
const newCode = allocateResult.data.generatedCode;
@ -3494,10 +3500,13 @@ export class ButtonActionExecutor {
const screenModalEvent = new CustomEvent("openScreenModal", {
detail: {
screenId: config.targetScreenId,
title: config.editModalTitle || "데이터 수정",
title: isCreateMode ? config.editModalTitle || "데이터 복사" : config.editModalTitle || "데이터 수정",
description: description,
size: config.modalSize || "lg",
editData: rowData, // 🆕 수정 데이터 전달
// 🔧 복사 모드에서는 editData 대신 splitPanelParentData로 전달하여 채번이 생성되도록 함
editData: isCreateMode ? undefined : rowData,
splitPanelParentData: isCreateMode ? rowData : undefined,
isCreateMode: isCreateMode, // 🆕 복사 모드 플래그 전달
},
});
window.dispatchEvent(screenModalEvent);

View File

@ -107,18 +107,18 @@ export const WEB_TYPE_V2_MAPPING: Record<string, V2ComponentMapping> = {
config: { mode: "dropdown", source: "category" },
},
// 파일/이미지 → V2Media
// 파일/이미지 → 레거시 file-upload (안정적인 파일 업로드)
file: {
componentType: "v2-media",
config: { type: "file", multiple: false },
componentType: "file-upload",
config: { maxFileCount: 10, accept: "*/*" },
},
image: {
componentType: "v2-media",
config: { type: "image", showPreview: true },
componentType: "file-upload",
config: { maxFileCount: 1, accept: "image/*" },
},
img: {
componentType: "v2-media",
config: { type: "image", showPreview: true },
componentType: "file-upload",
config: { maxFileCount: 1, accept: "image/*" },
},
// 버튼은 V2 컴포넌트에서 제외 (기존 버튼 시스템 사용)
@ -157,9 +157,9 @@ export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
code: "v2-select",
entity: "v2-select",
category: "v2-select",
file: "v2-media",
image: "v2-media",
img: "v2-media",
file: "file-upload",
image: "file-upload",
img: "file-upload",
button: "button-primary",
label: "v2-input",
};

View File

@ -232,13 +232,27 @@ export interface V2MediaConfig {
maxSize?: number;
preview?: boolean;
uploadEndpoint?: string;
// 레거시 FileUpload 호환 설정
docType?: string;
docTypeName?: string;
showFileList?: boolean;
dragDrop?: boolean;
}
export interface V2MediaProps extends V2BaseProps {
v2Type: "V2Media";
config: V2MediaConfig;
v2Type?: "V2Media";
config?: V2MediaConfig;
value?: string | string[]; // 파일 URL 또는 배열
onChange?: (value: string | string[]) => void;
// 레거시 FileUpload 호환 props
formData?: Record<string, any>;
columnName?: string;
tableName?: string;
// 부모 컴포넌트 시그니처: (fieldName, value) 형식
onFormDataChange?: (fieldName: string, value: any) => void;
isDesignMode?: boolean;
isInteractive?: boolean;
onUpdate?: (updates: Partial<any>) => void;
}
// ===== V2List =====

View File

@ -26,12 +26,14 @@ if %errorlevel% neq 0 (
echo [OK] Docker Desktop이 실행 중입니다.
echo.
REM 기존 컨테이너 정리
echo [2/5] 기존 컨테이너 정리 중...
REM 기존 컨테이너 및 이미지 정리
echo [2/5] 기존 컨테이너 및 이미지 정리 중...
docker rm -f pms-backend-win pms-frontend-win 2>nul
docker rmi -f erp-node-backend erp-node-frontend 2>nul
docker network rm pms-network 2>nul
docker network create pms-network 2>nul
echo [OK] 컨테이너 정리 완료
docker system prune -f >nul 2>&1
echo [OK] 컨테이너 및 이미지 정리 완료
echo.
REM 병렬 빌드 (docker-compose 자체가 병렬 처리)
@ -39,8 +41,8 @@ echo [3/5] 이미지 빌드 중... (백엔드 + 프론트엔드 병렬)
echo 이 작업은 시간이 걸릴 수 있습니다...
echo.
REM 백엔드 빌드
docker-compose -f docker-compose.backend.win.yml build
REM 백엔드 빌드 (캐시 없이 완전 재빌드)
docker-compose -f docker-compose.backend.win.yml build --no-cache
if %errorlevel% neq 0 (
echo [ERROR] 백엔드 빌드 실패!
pause
@ -49,8 +51,8 @@ if %errorlevel% neq 0 (
echo [OK] 백엔드 빌드 완료
echo.
REM 프론트엔드 빌드
docker-compose -f docker-compose.frontend.win.yml build
REM 프론트엔드 빌드 (캐시 없이 완전 재빌드)
docker-compose -f docker-compose.frontend.win.yml build --no-cache
if %errorlevel% neq 0 (
echo [ERROR] 프론트엔드 빌드 실패!
pause