Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map

This commit is contained in:
dohyeons 2025-11-28 10:47:55 +09:00
commit b70ed8aaff
37 changed files with 1658 additions and 750 deletions

View File

@ -132,6 +132,16 @@ router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Respo
return res.status(400).json({ success: false, error: "최소 1개 이상의 규칙 파트가 필요합니다" });
}
// 🆕 scopeType이 'table'인 경우 tableName 필수 체크
if (ruleConfig.scopeType === "table") {
if (!ruleConfig.tableName || ruleConfig.tableName.trim() === "") {
return res.status(400).json({
success: false,
error: "테이블 범위 규칙은 테이블명(tableName)이 필수입니다",
});
}
}
const newRule = await numberingRuleService.createRule(ruleConfig, companyCode, userId);
logger.info("✅ [POST /numbering-rules] 채번 규칙 생성 성공:", {

View File

@ -99,10 +99,18 @@ export class DynamicFormService {
}
try {
// YYYY-MM-DD 형식인 경우 시간 추가해서 Date 객체 생성
// YYYY-MM-DD 형식인 경우
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
console.log(`📅 날짜 타입 변환: ${value} -> Date 객체`);
return new Date(value + "T00:00:00");
// DATE 타입이면 문자열 그대로 유지
if (lowerDataType === "date") {
console.log(`📅 날짜 문자열 유지: ${value} -> "${value}" (DATE 타입)`);
return value; // 문자열 그대로 반환
}
// TIMESTAMP 타입이면 Date 객체로 변환
else {
console.log(`📅 날짜시간 변환: ${value} -> Date 객체 (TIMESTAMP 타입)`);
return new Date(value + "T00:00:00");
}
}
// 다른 날짜 형식도 Date 객체로 변환
else {
@ -300,13 +308,13 @@ export class DynamicFormService {
) {
// YYYY-MM-DD HH:mm:ss 형태의 문자열을 Date 객체로 변환
if (value.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)) {
console.log(`📅 날짜 변환: ${key} = "${value}" -> Date 객체`);
console.log(`📅 날짜시간 변환: ${key} = "${value}" -> Date 객체`);
dataToInsert[key] = new Date(value);
}
// YYYY-MM-DD 형태의 문자열을 Date 객체로 변환
// YYYY-MM-DD 형태의 문자열은 그대로 유지 (DATE 타입으로 저장)
else if (value.match(/^\d{4}-\d{2}-\d{2}$/)) {
console.log(`📅 날짜 변환: ${key} = "${value}" -> Date 객체`);
dataToInsert[key] = new Date(value + "T00:00:00");
console.log(`📅 날짜 유지: ${key} = "${value}" -> 문자열 그대로 (DATE 타입)`);
// dataToInsert[key] = value; // 문자열 그대로 유지 (이미 올바른 형식)
}
}
});
@ -849,10 +857,22 @@ export class DynamicFormService {
const values: any[] = Object.values(changedFields);
values.push(id); // WHERE 조건용 ID 추가
// 🔑 Primary Key 타입에 맞게 캐스팅
const pkDataType = columnTypes[primaryKeyColumn];
let pkCast = '';
if (pkDataType === 'integer' || pkDataType === 'bigint' || pkDataType === 'smallint') {
pkCast = '::integer';
} else if (pkDataType === 'numeric' || pkDataType === 'decimal') {
pkCast = '::numeric';
} else if (pkDataType === 'uuid') {
pkCast = '::uuid';
}
// text, varchar 등은 캐스팅 불필요
const updateQuery = `
UPDATE ${tableName}
SET ${setClause}
WHERE ${primaryKeyColumn} = $${values.length}::text
WHERE ${primaryKeyColumn} = $${values.length}${pkCast}
RETURNING *
`;

View File

@ -1418,9 +1418,9 @@ export class ScreenManagementService {
console.log(`=== 레이아웃 로드 시작 ===`);
console.log(`화면 ID: ${screenId}`);
// 권한 확인
const screens = await query<{ company_code: string | null }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
// 권한 확인 및 테이블명 조회
const screens = await query<{ company_code: string | null; table_name: string | null }>(
`SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId]
);
@ -1512,11 +1512,13 @@ export class ScreenManagementService {
console.log(`반환할 컴포넌트 수: ${components.length}`);
console.log(`최종 격자 설정:`, gridSettings);
console.log(`최종 해상도 설정:`, screenResolution);
console.log(`테이블명:`, existingScreen.table_name);
return {
components,
gridSettings,
screenResolution,
tableName: existingScreen.table_name, // 🆕 테이블명 추가
};
}

View File

@ -1165,6 +1165,23 @@ export class TableManagementService {
paramCount: number;
} | null> {
try {
// 🔧 날짜 범위 문자열 "YYYY-MM-DD|YYYY-MM-DD" 체크 (최우선!)
if (typeof value === "string" && value.includes("|")) {
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) {
return this.buildDateRangeCondition(columnName, value, paramIndex);
}
}
// 🔧 날짜 범위 객체 {from, to} 체크
if (typeof value === "object" && value !== null && ("from" in value || "to" in value)) {
// 날짜 범위 객체는 그대로 전달
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) {
return this.buildDateRangeCondition(columnName, value, paramIndex);
}
}
// 🔧 {value, operator} 형태의 필터 객체 처리
let actualValue = value;
let operator = "contains"; // 기본값
@ -1193,6 +1210,12 @@ export class TableManagementService {
// 컬럼 타입 정보 조회
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
logger.info(`🔍 [buildAdvancedSearchCondition] ${tableName}.${columnName}`,
`webType=${columnInfo?.webType || 'NULL'}`,
`inputType=${columnInfo?.inputType || 'NULL'}`,
`actualValue=${JSON.stringify(actualValue)}`,
`operator=${operator}`
);
if (!columnInfo) {
// 컬럼 정보가 없으면 operator에 따른 기본 검색
@ -1292,20 +1315,41 @@ export class TableManagementService {
const values: any[] = [];
let paramCount = 0;
if (typeof value === "object" && value !== null) {
// 문자열 형식의 날짜 범위 파싱 ("YYYY-MM-DD|YYYY-MM-DD")
if (typeof value === "string" && value.includes("|")) {
const [fromStr, toStr] = value.split("|");
if (fromStr && fromStr.trim() !== "") {
// VARCHAR 컬럼을 DATE로 캐스팅하여 비교
conditions.push(`${columnName}::date >= $${paramIndex + paramCount}::date`);
values.push(fromStr.trim());
paramCount++;
}
if (toStr && toStr.trim() !== "") {
// 종료일은 해당 날짜의 23:59:59까지 포함
conditions.push(`${columnName}::date <= $${paramIndex + paramCount}::date`);
values.push(toStr.trim());
paramCount++;
}
}
// 객체 형식의 날짜 범위 ({from, to})
else if (typeof value === "object" && value !== null) {
if (value.from) {
conditions.push(`${columnName} >= $${paramIndex + paramCount}`);
// VARCHAR 컬럼을 DATE로 캐스팅하여 비교
conditions.push(`${columnName}::date >= $${paramIndex + paramCount}::date`);
values.push(value.from);
paramCount++;
}
if (value.to) {
conditions.push(`${columnName} <= $${paramIndex + paramCount}`);
// 종료일은 해당 날짜의 23:59:59까지 포함
conditions.push(`${columnName}::date <= $${paramIndex + paramCount}::date`);
values.push(value.to);
paramCount++;
}
} else if (typeof value === "string" && value.trim() !== "") {
// 단일 날짜 검색 (해당 날짜의 데이터)
conditions.push(`DATE(${columnName}) = DATE($${paramIndex})`);
}
// 단일 날짜 검색
else if (typeof value === "string" && value.trim() !== "") {
conditions.push(`${columnName}::date = $${paramIndex}::date`);
values.push(value);
paramCount = 1;
}
@ -1544,6 +1588,7 @@ export class TableManagementService {
columnName: string
): Promise<{
webType: string;
inputType?: string;
codeCategory?: string;
referenceTable?: string;
referenceColumn?: string;
@ -1552,29 +1597,44 @@ export class TableManagementService {
try {
const result = await queryOne<{
web_type: string | null;
input_type: string | null;
code_category: string | null;
reference_table: string | null;
reference_column: string | null;
display_column: string | null;
}>(
`SELECT web_type, code_category, reference_table, reference_column, display_column
`SELECT web_type, input_type, code_category, reference_table, reference_column, display_column
FROM column_labels
WHERE table_name = $1 AND column_name = $2
LIMIT 1`,
[tableName, columnName]
);
logger.info(`🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`, {
found: !!result,
web_type: result?.web_type,
input_type: result?.input_type,
});
if (!result) {
logger.warn(`⚠️ [getColumnWebTypeInfo] 컬럼 정보 없음: ${tableName}.${columnName}`);
return null;
}
return {
webType: result.web_type || "",
// web_type이 없으면 input_type을 사용 (레거시 호환)
const webType = result.web_type || result.input_type || "";
const columnInfo = {
webType: webType,
inputType: result.input_type || "",
codeCategory: result.code_category || undefined,
referenceTable: result.reference_table || undefined,
referenceColumn: result.reference_column || undefined,
displayColumn: result.display_column || undefined,
};
logger.info(`✅ [getColumnWebTypeInfo] 반환값: webType=${columnInfo.webType}, inputType=${columnInfo.inputType}`);
return columnInfo;
} catch (error) {
logger.error(
`컬럼 웹타입 정보 조회 실패: ${tableName}.${columnName}`,

View File

@ -101,6 +101,7 @@ export interface LayoutData {
components: ComponentData[];
gridSettings?: GridSettings;
screenResolution?: ScreenResolution;
tableName?: string; // 🆕 화면에 연결된 테이블명
}
// 그리드 설정

View File

@ -1108,14 +1108,11 @@ export default function TableManagementPage() {
) : (
<div className="space-y-4">
{/* 컬럼 헤더 */}
<div className="text-foreground flex h-12 items-center border-b px-6 py-3 text-sm font-semibold">
<div className="w-40 pr-4"></div>
<div className="w-48 px-4"></div>
<div className="w-48 pr-6"> </div>
<div className="flex-1 pl-6" style={{ maxWidth: "calc(100% - 808px)" }}>
</div>
<div className="w-80 pl-4"></div>
<div className="text-foreground grid h-12 items-center border-b px-6 py-3 text-sm font-semibold" style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}>
<div className="pr-4"></div>
<div className="px-4"></div>
<div className="pr-6"> </div>
<div className="pl-4"></div>
</div>
{/* 컬럼 리스트 */}
@ -1132,12 +1129,13 @@ export default function TableManagementPage() {
{columns.map((column, index) => (
<div
key={column.columnName}
className="bg-background hover:bg-muted/50 flex min-h-16 items-center border-b px-6 py-3 transition-colors"
className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors"
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
>
<div className="w-40 pr-4">
<div className="pr-4 pt-1">
<div className="font-mono text-sm">{column.columnName}</div>
</div>
<div className="w-48 px-4">
<div className="px-4">
<Input
value={column.displayName || ""}
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
@ -1145,107 +1143,106 @@ export default function TableManagementPage() {
className="h-8 text-xs"
/>
</div>
<div className="w-48 pr-6">
<Select
value={column.inputType || "text"}
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="입력 타입 선택" />
</SelectTrigger>
<SelectContent>
{memoizedInputTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 pl-6" style={{ maxWidth: "calc(100% - 808px)" }}>
{/* 입력 타입이 'code'인 경우 공통코드 선택 */}
{column.inputType === "code" && (
<div className="pr-6">
<div className="space-y-3">
{/* 입력 타입 선택 */}
<Select
value={column.codeCategory || "none"}
onValueChange={(value) =>
handleDetailSettingsChange(column.columnName, "code", value)
}
value={column.inputType || "text"}
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="공통코드 선택" />
<SelectValue placeholder="입력 타입 선택" />
</SelectTrigger>
<SelectContent>
{commonCodeOptions.map((option, index) => (
<SelectItem key={`code-${option.value}-${index}`} value={option.value}>
{memoizedInputTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */}
{column.inputType === "category" && (
<div className="space-y-2">
<label className="text-muted-foreground mb-1 block text-xs">
(2)
</label>
<div className="border rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
{secondLevelMenus.length === 0 ? (
<p className="text-xs text-muted-foreground">
2 . .
</p>
) : (
secondLevelMenus.map((menu) => {
// menuObjid를 숫자로 변환하여 비교
const menuObjidNum = Number(menu.menuObjid);
const isChecked = (column.categoryMenus || []).includes(menuObjidNum);
return (
<div key={menu.menuObjid} className="flex items-center gap-2">
<input
type="checkbox"
id={`category-menu-${column.columnName}-${menu.menuObjid}`}
checked={isChecked}
onChange={(e) => {
const currentMenus = column.categoryMenus || [];
const newMenus = e.target.checked
? [...currentMenus, menuObjidNum]
: currentMenus.filter((id) => id !== menuObjidNum);
{/* 입력 타입이 'code'인 경우 공통코드 선택 */}
{column.inputType === "code" && (
<Select
value={column.codeCategory || "none"}
onValueChange={(value) =>
handleDetailSettingsChange(column.columnName, "code", value)
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="공통코드 선택" />
</SelectTrigger>
<SelectContent>
{commonCodeOptions.map((option, index) => (
<SelectItem key={`code-${option.value}-${index}`} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */}
{column.inputType === "category" && (
<div className="space-y-2">
<label className="text-muted-foreground mb-1 block text-xs">
(2)
</label>
<div className="border rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
{secondLevelMenus.length === 0 ? (
<p className="text-xs text-muted-foreground">
2 . .
</p>
) : (
secondLevelMenus.map((menu) => {
// menuObjid를 숫자로 변환하여 비교
const menuObjidNum = Number(menu.menuObjid);
const isChecked = (column.categoryMenus || []).includes(menuObjidNum);
return (
<div key={menu.menuObjid} className="flex items-center gap-2">
<input
type="checkbox"
id={`category-menu-${column.columnName}-${menu.menuObjid}`}
checked={isChecked}
onChange={(e) => {
const currentMenus = column.categoryMenus || [];
const newMenus = e.target.checked
? [...currentMenus, menuObjidNum]
: currentMenus.filter((id) => id !== menuObjidNum);
setColumns((prev) =>
prev.map((col) =>
col.columnName === column.columnName
? { ...col, categoryMenus: newMenus }
: col
)
);
}}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring"
/>
<label
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`}
className="text-xs cursor-pointer flex-1"
>
{menu.parentMenuName} {menu.menuName}
</label>
</div>
);
})
setColumns((prev) =>
prev.map((col) =>
col.columnName === column.columnName
? { ...col, categoryMenus: newMenus }
: col
)
);
}}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring"
/>
<label
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`}
className="text-xs cursor-pointer flex-1"
>
{menu.parentMenuName} {menu.menuName}
</label>
</div>
);
})
)}
</div>
{column.categoryMenus && column.categoryMenus.length > 0 && (
<p className="text-primary text-xs">
{column.categoryMenus.length}
</p>
)}
</div>
{column.categoryMenus && column.categoryMenus.length > 0 && (
<p className="text-primary text-xs">
{column.categoryMenus.length}
</p>
)}
</div>
)}
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
{column.inputType === "entity" && (
<div className="space-y-2">
<div className="grid grid-cols-3 gap-2">
)}
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
{column.inputType === "entity" && (
<>
{/* 참조 테이블 */}
<div>
<div className="w-48">
<label className="text-muted-foreground mb-1 block text-xs">
</label>
@ -1255,7 +1252,7 @@ export default function TableManagementPage() {
handleDetailSettingsChange(column.columnName, "entity", value)
}
>
<SelectTrigger className="bg-background h-8 text-xs">
<SelectTrigger className="bg-background h-8 w-full text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
@ -1278,7 +1275,7 @@ export default function TableManagementPage() {
{/* 조인 컬럼 */}
{column.referenceTable && column.referenceTable !== "none" && (
<div>
<div className="w-48">
<label className="text-muted-foreground mb-1 block text-xs">
</label>
@ -1292,7 +1289,7 @@ export default function TableManagementPage() {
)
}
>
<SelectTrigger className="bg-background h-8 text-xs">
<SelectTrigger className="bg-background h-8 w-full text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
@ -1324,7 +1321,7 @@ export default function TableManagementPage() {
column.referenceTable !== "none" &&
column.referenceColumn &&
column.referenceColumn !== "none" && (
<div>
<div className="w-48">
<label className="text-muted-foreground mb-1 block text-xs">
</label>
@ -1338,7 +1335,7 @@ export default function TableManagementPage() {
)
}
>
<SelectTrigger className="bg-background h-8 text-xs">
<SelectTrigger className="bg-background h-8 w-full text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
@ -1364,37 +1361,29 @@ export default function TableManagementPage() {
</Select>
</div>
)}
</div>
{/* 설정 완료 표시 */}
{column.referenceTable &&
column.referenceTable !== "none" &&
column.referenceColumn &&
column.referenceColumn !== "none" &&
column.displayColumn &&
column.displayColumn !== "none" && (
<div className="bg-primary/10 text-primary mt-2 flex items-center gap-1 rounded px-2 py-1 text-xs">
<span></span>
<span className="truncate">
{column.columnName} {column.referenceTable}.{column.displayColumn}
</span>
</div>
)}
</div>
)}
{/* 다른 입력 타입인 경우 빈 공간 */}
{column.inputType !== "code" && column.inputType !== "category" && column.inputType !== "entity" && (
<div className="text-muted-foreground flex h-8 items-center justify-center text-xs">
-
</div>
)}
{/* 설정 완료 표시 */}
{column.referenceTable &&
column.referenceTable !== "none" &&
column.referenceColumn &&
column.referenceColumn !== "none" &&
column.displayColumn &&
column.displayColumn !== "none" && (
<div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs w-48">
<span></span>
<span className="truncate"> </span>
</div>
)}
</>
)}
</div>
</div>
<div className="w-80 pl-4">
<div className="pl-4">
<Input
value={column.description || ""}
onChange={(e) => handleColumnChange(index, "description", e.target.value)}
placeholder="설명"
className="h-8 text-xs"
className="h-8 w-full text-xs"
/>
</div>
</div>
@ -1585,3 +1574,4 @@ export default function TableManagementPage() {
</div>
);
}

View File

@ -356,17 +356,6 @@ function ScreenViewPage() {
return isButton;
});
console.log(
"🔍 메뉴에서 발견된 전체 버튼:",
allButtons.map((b) => ({
id: b.id,
label: b.label,
positionX: b.position.x,
positionY: b.position.y,
width: b.size?.width,
height: b.size?.height,
})),
);
topLevelComponents.forEach((component) => {
const isButton =
@ -406,33 +395,13 @@ function ScreenViewPage() {
(c) => (c as any).componentId === "table-search-widget",
);
// 디버그: 모든 컴포넌트 타입 확인
console.log(
"🔍 전체 컴포넌트 타입:",
regularComponents.map((c) => ({
id: c.id,
type: c.type,
componentType: (c as any).componentType,
componentId: (c as any).componentId,
})),
);
// 🆕 조건부 컨테이너들을 찾기
// 조건부 컨테이너들을 찾기
const conditionalContainers = regularComponents.filter(
(c) =>
(c as any).componentId === "conditional-container" ||
(c as any).componentType === "conditional-container",
);
console.log(
"🔍 조건부 컨테이너 발견:",
conditionalContainers.map((c) => ({
id: c.id,
y: c.position.y,
size: c.size,
})),
);
// TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 조정
const adjustedComponents = regularComponents.map((component) => {
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
@ -520,12 +489,6 @@ function ScreenViewPage() {
columnOrder={tableColumnOrder}
tableDisplayData={tableDisplayData}
onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder, tableDisplayData) => {
console.log("🔍 화면에서 선택된 행 데이터:", selectedData);
console.log("📊 정렬 정보:", { sortBy, sortOrder, columnOrder });
console.log("📊 화면 표시 데이터:", {
count: tableDisplayData?.length,
firstRow: tableDisplayData?.[0],
});
setSelectedRowsData(selectedData);
setTableSortBy(sortBy);
setTableSortOrder(sortOrder || "asc");
@ -604,12 +567,6 @@ function ScreenViewPage() {
columnOrder,
tableDisplayData,
) => {
console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData);
console.log("📊 정렬 정보 (자식):", { sortBy, sortOrder, columnOrder });
console.log("📊 화면 표시 데이터 (자식):", {
count: tableDisplayData?.length,
firstRow: tableDisplayData?.[0],
});
setSelectedRowsData(selectedData);
setTableSortBy(sortBy);
setTableSortOrder(sortOrder || "asc");
@ -618,7 +575,6 @@ function ScreenViewPage() {
}}
refreshKey={tableRefreshKey}
onRefresh={() => {
console.log("🔄 테이블 새로고침 요청됨 (자식)");
setTableRefreshKey((prev) => prev + 1);
setSelectedRowsData([]); // 선택 해제
}}

View File

@ -152,7 +152,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
const ruleToSave = {
...currentRule,
scopeType: "table" as const, // ⚠️ 임시: DB 제약 조건 때문에 table 유지
tableName: currentTableName || currentRule.tableName || "", // 현재 테이블명 자동 설정
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 자동 설정 (빈 값은 null)
menuObjid: menuObjid || currentRule.menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용)
};

View File

@ -75,6 +75,13 @@ const ORDER_COLUMNS: RepeaterColumnConfig[] = [
calculated: true,
width: "120px",
},
{
field: "order_date",
label: "수주일",
type: "date",
editable: true,
width: "130px",
},
{
field: "delivery_date",
label: "납기일",

View File

@ -64,6 +64,9 @@ export function OrderRegistrationModal({
// 선택된 품목 목록
const [selectedItems, setSelectedItems] = useState<any[]>([]);
// 납기일 일괄 적용 플래그 (딱 한 번만 실행)
const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false);
// 저장 중
const [isSaving, setIsSaving] = useState(false);
@ -158,6 +161,45 @@ export function OrderRegistrationModal({
hsCode: "",
});
setSelectedItems([]);
setIsDeliveryDateApplied(false); // 플래그 초기화
};
// 품목 목록 변경 핸들러 (납기일 일괄 적용 로직 포함)
const handleItemsChange = (newItems: any[]) => {
// 1⃣ 플래그가 이미 true면 그냥 업데이트만 (일괄 적용 완료 상태)
if (isDeliveryDateApplied) {
setSelectedItems(newItems);
return;
}
// 2⃣ 품목이 없으면 그냥 업데이트
if (newItems.length === 0) {
setSelectedItems(newItems);
return;
}
// 3⃣ 현재 상태: 납기일이 있는 행과 없는 행 개수 체크
const itemsWithDate = newItems.filter((item) => item.delivery_date);
const itemsWithoutDate = newItems.filter((item) => !item.delivery_date);
// 4⃣ 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때 일괄 적용
if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) {
// 5⃣ 전체 일괄 적용
const selectedDate = itemsWithDate[0].delivery_date;
const updatedItems = newItems.map((item) => ({
...item,
delivery_date: selectedDate, // 모든 행에 동일한 납기일 적용
}));
setSelectedItems(updatedItems);
setIsDeliveryDateApplied(true); // 플래그 활성화 (다음부터는 일괄 적용 안 함)
console.log("✅ 납기일 일괄 적용 완료:", selectedDate);
console.log(` - 대상: ${itemsWithoutDate.length}개 행에 ${selectedDate} 적용`);
} else {
// 그냥 업데이트
setSelectedItems(newItems);
}
};
// 전체 금액 계산
@ -338,7 +380,7 @@ export function OrderRegistrationModal({
<Label className="text-xs sm:text-sm"> </Label>
<OrderItemRepeaterTable
value={selectedItems}
onChange={setSelectedItems}
onChange={handleItemsChange}
/>
</div>

View File

@ -316,6 +316,33 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
screenId: modalState.screenId,
});
// 🆕 날짜 필드 정규화 함수 (YYYY-MM-DD 형식으로 변환)
const normalizeDateField = (value: any): string | null => {
if (!value) return null;
// ISO 8601 형식 (2025-11-26T00:00:00.000Z) 또는 Date 객체
if (value instanceof Date || typeof value === "string") {
try {
const date = new Date(value);
if (isNaN(date.getTime())) return null;
// YYYY-MM-DD 형식으로 변환
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
} catch (error) {
console.warn("날짜 변환 실패:", value, error);
return null;
}
}
return null;
};
// 날짜 필드 목록
const dateFields = ["item_due_date", "delivery_date", "due_date", "order_date"];
let insertedCount = 0;
let updatedCount = 0;
let deletedCount = 0;
@ -333,6 +360,17 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
delete insertData.id; // id는 자동 생성되므로 제거
// 🆕 날짜 필드 정규화 (YYYY-MM-DD 형식으로 변환)
dateFields.forEach((fieldName) => {
if (insertData[fieldName]) {
const normalizedDate = normalizeDateField(insertData[fieldName]);
if (normalizedDate) {
insertData[fieldName] = normalizedDate;
console.log(`📅 [날짜 정규화] ${fieldName}: ${currentData[fieldName]}${normalizedDate}`);
}
}
});
// 🆕 groupByColumns의 값을 강제로 포함 (order_no 등)
if (modalState.groupByColumns && modalState.groupByColumns.length > 0) {
modalState.groupByColumns.forEach((colName) => {
@ -348,23 +386,32 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 🆕 공통 필드 추가 (거래처, 담당자, 납품처, 메모 등)
// formData에서 품목별 필드가 아닌 공통 필드를 복사
const commonFields = [
'partner_id', // 거래처
'manager_id', // 담당자
'delivery_partner_id', // 납품처
'delivery_address', // 납품장소
'memo', // 메모
'order_date', // 주문일
'due_date', // 납기일
'shipping_method', // 배송방법
'status', // 상태
'sales_type', // 영업유형
"partner_id", // 거래처
"manager_id", // 담당자
"delivery_partner_id", // 납품처
"delivery_address", // 납품장소
"memo", // 메모
"order_date", // 주문일
"due_date", // 납기일
"shipping_method", // 배송방법
"status", // 상태
"sales_type", // 영업유형
];
commonFields.forEach((fieldName) => {
// formData에 값이 있으면 추가
if (formData[fieldName] !== undefined && formData[fieldName] !== null) {
insertData[fieldName] = formData[fieldName];
console.log(`🔗 [공통 필드] ${fieldName} 값 추가:`, formData[fieldName]);
// 날짜 필드인 경우 정규화
if (dateFields.includes(fieldName)) {
const normalizedDate = normalizeDateField(formData[fieldName]);
if (normalizedDate) {
insertData[fieldName] = normalizedDate;
console.log(`🔗 [공통 필드 - 날짜] ${fieldName} 값 추가:`, normalizedDate);
}
} else {
insertData[fieldName] = formData[fieldName];
console.log(`🔗 [공통 필드] ${fieldName} 값 추가:`, formData[fieldName]);
}
}
});
@ -404,8 +451,15 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
// 🆕 값 정규화 함수 (타입 통일)
const normalizeValue = (val: any): any => {
const normalizeValue = (val: any, fieldName?: string): any => {
if (val === null || val === undefined || val === "") return null;
// 날짜 필드인 경우 YYYY-MM-DD 형식으로 정규화
if (fieldName && dateFields.includes(fieldName)) {
const normalizedDate = normalizeDateField(val);
return normalizedDate;
}
if (typeof val === "string" && !isNaN(Number(val))) {
// 숫자로 변환 가능한 문자열은 숫자로
return Number(val);
@ -422,13 +476,14 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
// 🆕 타입 정규화 후 비교
const currentValue = normalizeValue(currentData[key]);
const originalValue = normalizeValue(originalItemData[key]);
const currentValue = normalizeValue(currentData[key], key);
const originalValue = normalizeValue(originalItemData[key], key);
// 값이 변경된 경우만 포함
if (currentValue !== originalValue) {
console.log(`🔍 [품목 수정 감지] ${key}: ${originalValue}${currentValue}`);
changedData[key] = currentData[key]; // 원본 값 사용 (문자열 그대로)
// 날짜 필드는 정규화된 값 사용, 나머지는 원본 값 사용
changedData[key] = dateFields.includes(key) ? currentValue : currentData[key];
}
});
@ -631,13 +686,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
maxHeight: "100%",
}}
>
{/* 🆕 그룹 데이터가 있으면 안내 메시지 표시 */}
{groupData.length > 1 && (
<div className="absolute left-4 top-4 z-10 rounded-md bg-blue-50 px-3 py-2 text-xs text-blue-700 shadow-sm">
{groupData.length}
</div>
)}
{screenData.components.map((component) => {
// 컴포넌트 위치를 offset만큼 조정
const offsetX = screenDimensions?.offsetX || 0;

View File

@ -433,7 +433,10 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
return (
<div className="h-full w-full">
<TabsWidget component={tabsComponent as any} />
<TabsWidget
component={tabsComponent as any}
menuObjid={menuObjid} // 🆕 부모의 menuObjid 전달
/>
</div>
);
}

View File

@ -39,6 +39,7 @@ interface InteractiveScreenViewerProps {
id: number;
tableName?: string;
};
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용)
onSave?: () => Promise<void>;
onRefresh?: () => void;
onFlowRefresh?: () => void;
@ -61,6 +62,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
onFormDataChange,
hideLabel = false,
screenInfo,
menuObjid,
onSave,
onRefresh,
onFlowRefresh,
@ -332,6 +334,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
onFormDataChange={handleFormDataChange}
screenId={screenInfo?.id}
tableName={screenInfo?.tableName}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
userId={user?.userId} // ✅ 사용자 ID 전달
userName={user?.userName} // ✅ 사용자 이름 전달
companyCode={user?.companyCode} // ✅ 회사 코드 전달

View File

@ -47,6 +47,9 @@ import dynamic from "next/dynamic";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { DynamicWebTypeRenderer } from "@/lib/registry";
import { isFileComponent, getComponentWebType } from "@/lib/utils/componentTypeUtils";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { RealtimePreview } from "./RealtimePreviewDynamic";
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
// InteractiveScreenViewer를 동적으로 import (SSR 비활성화)
const InteractiveScreenViewer = dynamic(
@ -1315,24 +1318,40 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
<DialogHeader>
<DialogTitle> - {screenToPreview?.screenName}</DialogTitle>
</DialogHeader>
<div className="flex flex-1 items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-slate-100 p-6">
{isLoadingPreview ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mb-2 text-lg font-medium"> ...</div>
<div className="text-muted-foreground text-sm"> .</div>
<ScreenPreviewProvider isPreviewMode={true}>
<TableOptionsProvider>
<div className="flex flex-1 items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-slate-100 p-6">
{isLoadingPreview ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mb-2 text-lg font-medium"> ...</div>
<div className="text-muted-foreground text-sm"> .</div>
</div>
</div>
</div>
) : previewLayout && previewLayout.components ? (
) : previewLayout && previewLayout.components ? (
(() => {
const screenWidth = previewLayout.screenResolution?.width || 1200;
const screenHeight = previewLayout.screenResolution?.height || 800;
// 모달 내부 가용 공간 계산 (헤더, 푸터, 패딩 제외)
const availableWidth = typeof window !== "undefined" ? window.innerWidth * 0.95 - 100 : 1800; // 95vw - 패딩
const modalPadding = 100; // 헤더 + 푸터 + 패딩
const availableWidth = typeof window !== "undefined" ? window.innerWidth * 0.95 - modalPadding : 1700;
const availableHeight = typeof window !== "undefined" ? window.innerHeight * 0.95 - modalPadding : 900;
// 가로폭 기준으로 스케일 계산 (가로폭에 맞춤)
const scale = availableWidth / screenWidth;
// 가로/세로 비율을 모두 고려하여 작은 쪽에 맞춤 (화면이 잘리지 않도록)
const scaleX = availableWidth / screenWidth;
const scaleY = availableHeight / screenHeight;
const scale = Math.min(scaleX, scaleY, 1); // 최대 1배율 (확대 방지)
console.log("📐 미리보기 스케일 계산:", {
screenWidth,
screenHeight,
availableWidth,
availableHeight,
scaleX,
scaleY,
finalScale: scale,
});
return (
<div
@ -1414,115 +1433,61 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
);
}
// 라벨 표시 여부 계산
const templateTypes = ["datatable"];
const shouldShowLabel =
component.style?.labelDisplay !== false &&
(component.label || component.style?.labelText) &&
!templateTypes.includes(component.type);
const labelText = component.style?.labelText || component.label || "";
const labelStyle = {
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#212121",
fontWeight: component.style?.labelFontWeight || "500",
backgroundColor: component.style?.labelBackgroundColor || "transparent",
};
const labelMarginBottom = component.style?.labelMarginBottom || "4px";
// 일반 컴포넌트 렌더링
// 일반 컴포넌트 렌더링 - RealtimePreview 사용 (실제 화면과 동일)
return (
<div key={component.id}>
{/* 라벨을 외부에 별도로 렌더링 */}
{shouldShowLabel && (
<div
style={{
position: "absolute",
left: `${component.position.x}px`,
top: `${component.position.y - 25}px`, // 컴포넌트 위쪽에 라벨 배치
zIndex: (component.position.z || 1) + 1,
...labelStyle,
}}
>
{labelText}
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
</div>
)}
<RealtimePreview
key={component.id}
component={component}
isSelected={false}
isDesignMode={false}
onClick={() => {}}
screenId={screenToPreview!.screenId}
tableName={screenToPreview?.tableName}
formData={previewFormData}
onFormDataChange={(fieldName, value) => {
setPreviewFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}}
>
{/* 자식 컴포넌트들 */}
{(component.type === "group" ||
component.type === "container" ||
component.type === "area") &&
previewLayout.components
.filter((child: any) => child.parentId === component.id)
.map((child: any) => {
// 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
const relativeChildComponent = {
...child,
position: {
x: child.position.x - component.position.x,
y: child.position.y - component.position.y,
z: child.position.z || 1,
},
};
{/* 실제 컴포넌트 */}
<div
style={(() => {
const style = {
position: "absolute" as const,
left: `${component.position.x}px`,
top: `${component.position.y}px`,
width: component.style?.width || `${component.size.width}px`,
height: component.style?.height || `${component.size.height}px`,
zIndex: component.position.z || 1,
};
return style;
})()}
>
{/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */}
{component.type !== "widget" ? (
<DynamicComponentRenderer
component={{
...component,
style: {
...component.style,
labelDisplay: shouldShowLabel ? false : (component.style?.labelDisplay ?? true), // 상위에서 라벨을 표시했으면 컴포넌트 내부에서는 숨김
},
}}
isInteractive={true}
formData={previewFormData}
onFormDataChange={(fieldName, value) => {
setPreviewFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}}
screenId={screenToPreview!.screenId}
tableName={screenToPreview?.tableName}
/>
) : (
<DynamicWebTypeRenderer
webType={(() => {
// 유틸리티 함수로 파일 컴포넌트 감지
if (isFileComponent(component)) {
return "file";
}
// 다른 컴포넌트는 유틸리티 함수로 webType 결정
return getComponentWebType(component) || "text";
})()}
config={component.webTypeConfig}
props={{
component: component,
value: previewFormData[component.columnName || component.id] || "",
onChange: (value: any) => {
const fieldName = component.columnName || component.id;
setPreviewFormData((prev) => ({
...prev,
[fieldName]: value,
}));
},
onFormDataChange: (fieldName, value) => {
setPreviewFormData((prev) => ({
...prev,
[fieldName]: value,
}));
},
isInteractive: true,
formData: previewFormData,
readonly: component.readonly,
required: component.required,
placeholder: component.placeholder,
className: "w-full h-full",
}}
/>
)}
</div>
</div>
return (
<RealtimePreview
key={child.id}
component={relativeChildComponent}
isSelected={false}
isDesignMode={false}
onClick={() => {}}
screenId={screenToPreview!.screenId}
tableName={screenToPreview?.tableName}
formData={previewFormData}
onFormDataChange={(fieldName, value) => {
setPreviewFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}}
/>
);
})}
</RealtimePreview>
);
})}
</div>
@ -1536,7 +1501,9 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
</div>
</div>
)}
</div>
</div>
</TableOptionsProvider>
</ScreenPreviewProvider>
<DialogFooter>
<Button variant="outline" onClick={() => setPreviewDialogOpen(false)}>

View File

@ -47,6 +47,14 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
// 새 옵션 추가용 상태
const [newOptionLabel, setNewOptionLabel] = useState("");
const [newOptionValue, setNewOptionValue] = useState("");
// 입력 필드용 로컬 상태
const [localInputs, setLocalInputs] = useState({
label: config.label || "",
checkedValue: config.checkedValue || "Y",
uncheckedValue: config.uncheckedValue || "N",
groupLabel: config.groupLabel || "",
});
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
@ -63,6 +71,14 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
readonly: currentConfig.readonly || false,
inline: currentConfig.inline !== false,
});
// 입력 필드 로컬 상태도 동기화
setLocalInputs({
label: currentConfig.label || "",
checkedValue: currentConfig.checkedValue || "Y",
uncheckedValue: currentConfig.uncheckedValue || "N",
groupLabel: currentConfig.groupLabel || "",
});
}, [widget.webTypeConfig]);
// 설정 업데이트 핸들러
@ -107,11 +123,16 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
updateConfig("options", newOptions);
};
// 옵션 업데이트
const updateOption = (index: number, field: keyof CheckboxOption, value: any) => {
// 옵션 업데이트 (입력 필드용 - 로컬 상태만)
const updateOptionLocal = (index: number, field: keyof CheckboxOption, value: any) => {
const newOptions = [...localConfig.options];
newOptions[index] = { ...newOptions[index], [field]: value };
updateConfig("options", newOptions);
setLocalConfig({ ...localConfig, options: newOptions });
};
// 옵션 업데이트 완료 (onBlur)
const handleOptionBlur = () => {
onUpdateProperty("webTypeConfig", localConfig);
};
return (
@ -170,8 +191,9 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</Label>
<Input
id="label"
value={localConfig.label || ""}
onChange={(e) => updateConfig("label", e.target.value)}
value={localInputs.label}
onChange={(e) => setLocalInputs({ ...localInputs, label: e.target.value })}
onBlur={() => updateConfig("label", localInputs.label)}
placeholder="체크박스 라벨"
className="text-xs"
/>
@ -184,8 +206,9 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</Label>
<Input
id="checkedValue"
value={localConfig.checkedValue || ""}
onChange={(e) => updateConfig("checkedValue", e.target.value)}
value={localInputs.checkedValue}
onChange={(e) => setLocalInputs({ ...localInputs, checkedValue: e.target.value })}
onBlur={() => updateConfig("checkedValue", localInputs.checkedValue)}
placeholder="Y"
className="text-xs"
/>
@ -196,8 +219,9 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</Label>
<Input
id="uncheckedValue"
value={localConfig.uncheckedValue || ""}
onChange={(e) => updateConfig("uncheckedValue", e.target.value)}
value={localInputs.uncheckedValue}
onChange={(e) => setLocalInputs({ ...localInputs, uncheckedValue: e.target.value })}
onBlur={() => updateConfig("uncheckedValue", localInputs.uncheckedValue)}
placeholder="N"
className="text-xs"
/>
@ -229,8 +253,9 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</Label>
<Input
id="groupLabel"
value={localConfig.groupLabel || ""}
onChange={(e) => updateConfig("groupLabel", e.target.value)}
value={localInputs.groupLabel}
onChange={(e) => setLocalInputs({ ...localInputs, groupLabel: e.target.value })}
onBlur={() => updateConfig("groupLabel", localInputs.groupLabel)}
placeholder="체크박스 그룹 제목"
className="text-xs"
/>
@ -268,26 +293,40 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label className="text-xs"> ({localConfig.options.length})</Label>
<div className="max-h-40 space-y-2 overflow-y-auto">
{localConfig.options.map((option, index) => (
<div key={index} className="flex items-center gap-2 rounded border p-2">
<div key={`${option.value}-${index}`} className="flex items-center gap-2 rounded border p-2">
<Switch
checked={option.checked || false}
onCheckedChange={(checked) => updateOption(index, "checked", checked)}
onCheckedChange={(checked) => {
const newOptions = [...localConfig.options];
newOptions[index] = { ...newOptions[index], checked };
const newConfig = { ...localConfig, options: newOptions };
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
}}
/>
<Input
value={option.label}
onChange={(e) => updateOption(index, "label", e.target.value)}
onChange={(e) => updateOptionLocal(index, "label", e.target.value)}
onBlur={handleOptionBlur}
placeholder="라벨"
className="flex-1 text-xs"
/>
<Input
value={option.value}
onChange={(e) => updateOption(index, "value", e.target.value)}
onChange={(e) => updateOptionLocal(index, "value", e.target.value)}
onBlur={handleOptionBlur}
placeholder="값"
className="flex-1 text-xs"
/>
<Switch
checked={!option.disabled}
onCheckedChange={(checked) => updateOption(index, "disabled", !checked)}
onCheckedChange={(checked) => {
const newOptions = [...localConfig.options];
newOptions[index] = { ...newOptions[index], disabled: !checked };
const newConfig = { ...localConfig, options: newOptions };
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
}}
/>
<Button size="sm" variant="destructive" onClick={() => removeOption(index)} className="p-1 text-xs">
<Trash2 className="h-3 w-3" />

View File

@ -9,13 +9,14 @@ import { Switch } from "@/components/ui/switch";
import { Trash2, Plus } from "lucide-react";
import { ColumnFilter, DataFilterConfig } from "@/types/screen-management";
import { UnifiedColumnInfo } from "@/types/table-management";
import { apiClient } from "@/lib/api/client";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
interface DataFilterConfigPanelProps {
tableName?: string;
columns?: UnifiedColumnInfo[];
config?: DataFilterConfig;
onConfigChange: (config: DataFilterConfig) => void;
menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 값 조회 시 필요)
}
/**
@ -27,7 +28,15 @@ export function DataFilterConfigPanel({
columns = [],
config,
onConfigChange,
menuObjid, // 🆕 메뉴 OBJID
}: DataFilterConfigPanelProps) {
console.log("🔍 [DataFilterConfigPanel] 초기화:", {
tableName,
columnsCount: columns.length,
menuObjid,
sampleColumns: columns.slice(0, 3),
});
const [localConfig, setLocalConfig] = useState<DataFilterConfig>(
config || {
enabled: false,
@ -43,6 +52,14 @@ export function DataFilterConfigPanel({
useEffect(() => {
if (config) {
setLocalConfig(config);
// 🆕 기존 필터 중 카테고리 타입인 것들의 값을 로드
config.filters?.forEach((filter) => {
if (filter.valueType === "category" && filter.columnName) {
console.log("🔄 기존 카테고리 필터 감지, 값 로딩:", filter.columnName);
loadCategoryValues(filter.columnName);
}
});
}
}, [config]);
@ -55,20 +72,34 @@ export function DataFilterConfigPanel({
setLoadingCategories(prev => ({ ...prev, [columnName]: true }));
try {
const response = await apiClient.get(
`/table-categories/${tableName}/${columnName}/values`
console.log("🔍 카테고리 값 로드 시작:", {
tableName,
columnName,
menuObjid,
});
const response = await getCategoryValues(
tableName,
columnName,
false, // includeInactive
menuObjid // 🆕 메뉴 OBJID 전달
);
if (response.data.success && response.data.data) {
const values = response.data.data.map((item: any) => ({
console.log("📦 카테고리 값 로드 응답:", response);
if (response.success && response.data) {
const values = response.data.map((item: any) => ({
value: item.valueCode,
label: item.valueLabel,
}));
console.log("✅ 카테고리 값 설정:", { columnName, valuesCount: values.length });
setCategoryValues(prev => ({ ...prev, [columnName]: values }));
} else {
console.warn("⚠️ 카테고리 값 로드 실패 또는 데이터 없음:", response);
}
} catch (error) {
console.error(`카테고리 값 로드 실패 (${columnName}):`, error);
console.error(`카테고리 값 로드 실패 (${columnName}):`, error);
} finally {
setLoadingCategories(prev => ({ ...prev, [columnName]: false }));
}

View File

@ -51,32 +51,29 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
const [newFieldName, setNewFieldName] = useState("");
const [newFieldLabel, setNewFieldLabel] = useState("");
const [newFieldType, setNewFieldType] = useState("string");
const [isUserEditing, setIsUserEditing] = useState(false);
// 컴포넌트 변경 시 로컬 상태 동기화 (사용자가 입력 중이 아닐 때만)
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
if (!isUserEditing) {
const currentConfig = (widget.webTypeConfig as EntityTypeConfig) || {};
setLocalConfig({
entityType: currentConfig.entityType || "",
displayFields: currentConfig.displayFields || [],
searchFields: currentConfig.searchFields || [],
valueField: currentConfig.valueField || "id",
labelField: currentConfig.labelField || "name",
multiple: currentConfig.multiple || false,
searchable: currentConfig.searchable !== false,
placeholder: currentConfig.placeholder || "엔티티를 선택하세요",
emptyMessage: currentConfig.emptyMessage || "검색 결과가 없습니다",
pageSize: currentConfig.pageSize || 20,
minSearchLength: currentConfig.minSearchLength || 1,
defaultValue: currentConfig.defaultValue || "",
required: currentConfig.required || false,
readonly: currentConfig.readonly || false,
apiEndpoint: currentConfig.apiEndpoint || "",
filters: currentConfig.filters || {},
});
}
}, [widget.webTypeConfig, isUserEditing]);
const currentConfig = (widget.webTypeConfig as EntityTypeConfig) || {};
setLocalConfig({
entityType: currentConfig.entityType || "",
displayFields: currentConfig.displayFields || [],
searchFields: currentConfig.searchFields || [],
valueField: currentConfig.valueField || "id",
labelField: currentConfig.labelField || "name",
multiple: currentConfig.multiple || false,
searchable: currentConfig.searchable !== false,
placeholder: currentConfig.placeholder || "엔티티를 선택하세요",
emptyMessage: currentConfig.emptyMessage || "검색 결과가 없습니다",
pageSize: currentConfig.pageSize || 20,
minSearchLength: currentConfig.minSearchLength || 1,
defaultValue: currentConfig.defaultValue || "",
required: currentConfig.required || false,
readonly: currentConfig.readonly || false,
apiEndpoint: currentConfig.apiEndpoint || "",
filters: currentConfig.filters || {},
});
}, [widget.webTypeConfig]);
// 설정 업데이트 핸들러 (즉시 부모에게 전달 - 드롭다운, 체크박스 등)
const updateConfig = (field: keyof EntityTypeConfig, value: any) => {
@ -87,13 +84,11 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
// 입력 필드용 업데이트 (로컬 상태만)
const updateConfigLocal = (field: keyof EntityTypeConfig, value: any) => {
setIsUserEditing(true);
setLocalConfig({ ...localConfig, [field]: value });
};
// 입력 완료 시 부모에게 전달
const handleInputBlur = () => {
setIsUserEditing(false);
onUpdateProperty("webTypeConfig", localConfig);
};
@ -121,17 +116,15 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
updateConfig("displayFields", newFields);
};
// 필드 업데이트 (입력 중)
// 필드 업데이트 (입력 중) - 로컬 상태만 업데이트
const updateDisplayField = (index: number, field: keyof EntityField, value: any) => {
setIsUserEditing(true);
const newFields = [...localConfig.displayFields];
newFields[index] = { ...newFields[index], [field]: value };
setLocalConfig({ ...localConfig, displayFields: newFields });
};
// 필드 업데이트 완료 (onBlur)
// 필드 업데이트 완료 (onBlur) - 부모에게 전달
const handleFieldBlur = () => {
setIsUserEditing(false);
onUpdateProperty("webTypeConfig", localConfig);
};
@ -325,12 +318,15 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label className="text-xs"> ({localConfig.displayFields.length})</Label>
<div className="max-h-40 space-y-2 overflow-y-auto">
{localConfig.displayFields.map((field, index) => (
<div key={index} className="flex items-center gap-2 rounded border p-2">
<div key={`${field.name}-${index}`} className="flex items-center gap-2 rounded border p-2">
<Switch
checked={field.visible}
onCheckedChange={(checked) => {
updateDisplayField(index, "visible", checked);
handleFieldBlur();
const newFields = [...localConfig.displayFields];
newFields[index] = { ...newFields[index], visible: checked };
const newConfig = { ...localConfig, displayFields: newFields };
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
}}
/>
<Input
@ -347,7 +343,16 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
placeholder="라벨"
className="flex-1 text-xs"
/>
<Select value={field.type} onValueChange={(value) => updateDisplayField(index, "type", value)}>
<Select
value={field.type}
onValueChange={(value) => {
const newFields = [...localConfig.displayFields];
newFields[index] = { ...newFields[index], type: value };
const newConfig = { ...localConfig, displayFields: newFields };
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
}}
>
<SelectTrigger className="w-24 text-xs">
<SelectValue />
</SelectTrigger>

View File

@ -43,6 +43,12 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
const [newOptionLabel, setNewOptionLabel] = useState("");
const [newOptionValue, setNewOptionValue] = useState("");
const [bulkOptions, setBulkOptions] = useState("");
// 입력 필드용 로컬 상태
const [localInputs, setLocalInputs] = useState({
groupLabel: config.groupLabel || "",
groupName: config.groupName || "",
});
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
@ -59,6 +65,12 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
inline: currentConfig.inline !== false,
groupLabel: currentConfig.groupLabel || "",
});
// 입력 필드 로컬 상태도 동기화
setLocalInputs({
groupLabel: currentConfig.groupLabel || "",
groupName: currentConfig.groupName || "",
});
}, [widget.webTypeConfig]);
// 설정 업데이트 핸들러
@ -95,17 +107,24 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
}
};
// 옵션 업데이트
const updateOption = (index: number, field: keyof RadioOption, value: any) => {
// 옵션 업데이트 (입력 필드용 - 로컬 상태만)
const updateOptionLocal = (index: number, field: keyof RadioOption, value: any) => {
const newOptions = [...localConfig.options];
const oldValue = newOptions[index].value;
newOptions[index] = { ...newOptions[index], [field]: value };
updateConfig("options", newOptions);
// 값이 변경되고 해당 값이 기본값이었다면 기본값도 업데이트
const newConfig = { ...localConfig, options: newOptions };
if (field === "value" && localConfig.defaultValue === oldValue) {
updateConfig("defaultValue", value);
newConfig.defaultValue = value;
}
setLocalConfig(newConfig);
};
// 옵션 업데이트 완료 (onBlur)
const handleOptionBlur = () => {
onUpdateProperty("webTypeConfig", localConfig);
};
// 벌크 옵션 추가
@ -185,8 +204,9 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</Label>
<Input
id="groupLabel"
value={localConfig.groupLabel || ""}
onChange={(e) => updateConfig("groupLabel", e.target.value)}
value={localInputs.groupLabel}
onChange={(e) => setLocalInputs({ ...localInputs, groupLabel: e.target.value })}
onBlur={() => updateConfig("groupLabel", localInputs.groupLabel)}
placeholder="라디오버튼 그룹 제목"
className="text-xs"
/>
@ -198,8 +218,9 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</Label>
<Input
id="groupName"
value={localConfig.groupName || ""}
onChange={(e) => updateConfig("groupName", e.target.value)}
value={localInputs.groupName}
onChange={(e) => setLocalInputs({ ...localInputs, groupName: e.target.value })}
onBlur={() => updateConfig("groupName", localInputs.groupName)}
placeholder="자동 생성 (필드명 기반)"
className="text-xs"
/>
@ -290,22 +311,30 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label className="text-xs"> ({localConfig.options.length})</Label>
<div className="max-h-40 space-y-2 overflow-y-auto">
{localConfig.options.map((option, index) => (
<div key={index} className="flex items-center gap-2 rounded border p-2">
<div key={`${option.value}-${index}`} className="flex items-center gap-2 rounded border p-2">
<Input
value={option.label}
onChange={(e) => updateOption(index, "label", e.target.value)}
onChange={(e) => updateOptionLocal(index, "label", e.target.value)}
onBlur={handleOptionBlur}
placeholder="라벨"
className="flex-1 text-xs"
/>
<Input
value={option.value}
onChange={(e) => updateOption(index, "value", e.target.value)}
onChange={(e) => updateOptionLocal(index, "value", e.target.value)}
onBlur={handleOptionBlur}
placeholder="값"
className="flex-1 text-xs"
/>
<Switch
checked={!option.disabled}
onCheckedChange={(checked) => updateOption(index, "disabled", !checked)}
onCheckedChange={(checked) => {
const newOptions = [...localConfig.options];
newOptions[index] = { ...newOptions[index], disabled: !checked };
const newConfig = { ...localConfig, options: newOptions };
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
}}
/>
<Button size="sm" variant="destructive" onClick={() => removeOption(index)} className="p-1 text-xs">
<Trash2 className="h-3 w-3" />

View File

@ -44,6 +44,12 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
const [newOptionLabel, setNewOptionLabel] = useState("");
const [newOptionValue, setNewOptionValue] = useState("");
const [bulkOptions, setBulkOptions] = useState("");
// 입력 필드용 로컬 상태
const [localInputs, setLocalInputs] = useState({
placeholder: config.placeholder || "",
emptyMessage: config.emptyMessage || "",
});
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
@ -61,6 +67,12 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
readonly: currentConfig.readonly || false,
emptyMessage: currentConfig.emptyMessage || "선택 가능한 옵션이 없습니다",
});
// 입력 필드 로컬 상태도 동기화
setLocalInputs({
placeholder: currentConfig.placeholder || "",
emptyMessage: currentConfig.emptyMessage || "",
});
}, [widget.webTypeConfig]);
// 설정 업데이트 핸들러
@ -91,11 +103,16 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
updateConfig("options", newOptions);
};
// 옵션 업데이트
const updateOption = (index: number, field: keyof SelectOption, value: any) => {
// 옵션 업데이트 (입력 필드용 - 로컬 상태만)
const updateOptionLocal = (index: number, field: keyof SelectOption, value: any) => {
const newOptions = [...localConfig.options];
newOptions[index] = { ...newOptions[index], [field]: value };
updateConfig("options", newOptions);
setLocalConfig({ ...localConfig, options: newOptions });
};
// 옵션 업데이트 완료 (onBlur)
const handleOptionBlur = () => {
onUpdateProperty("webTypeConfig", localConfig);
};
// 벌크 옵션 추가
@ -170,8 +187,9 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</Label>
<Input
id="placeholder"
value={localConfig.placeholder || ""}
onChange={(e) => updateConfig("placeholder", e.target.value)}
value={localInputs.placeholder}
onChange={(e) => setLocalInputs({ ...localInputs, placeholder: e.target.value })}
onBlur={() => updateConfig("placeholder", localInputs.placeholder)}
placeholder="선택하세요"
className="text-xs"
/>
@ -183,8 +201,9 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</Label>
<Input
id="emptyMessage"
value={localConfig.emptyMessage || ""}
onChange={(e) => updateConfig("emptyMessage", e.target.value)}
value={localInputs.emptyMessage}
onChange={(e) => setLocalInputs({ ...localInputs, emptyMessage: e.target.value })}
onBlur={() => updateConfig("emptyMessage", localInputs.emptyMessage)}
placeholder="선택 가능한 옵션이 없습니다"
className="text-xs"
/>
@ -285,22 +304,30 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label className="text-xs"> ({localConfig.options.length})</Label>
<div className="max-h-40 space-y-2 overflow-y-auto">
{localConfig.options.map((option, index) => (
<div key={index} className="flex items-center gap-2 rounded border p-2">
<div key={`${option.value}-${index}`} className="flex items-center gap-2 rounded border p-2">
<Input
value={option.label}
onChange={(e) => updateOption(index, "label", e.target.value)}
onChange={(e) => updateOptionLocal(index, "label", e.target.value)}
onBlur={handleOptionBlur}
placeholder="라벨"
className="flex-1 text-xs"
/>
<Input
value={option.value}
onChange={(e) => updateOption(index, "value", e.target.value)}
onChange={(e) => updateOptionLocal(index, "value", e.target.value)}
onBlur={handleOptionBlur}
placeholder="값"
className="flex-1 text-xs"
/>
<Switch
checked={!option.disabled}
onCheckedChange={(checked) => updateOption(index, "disabled", !checked)}
onCheckedChange={(checked) => {
const newOptions = [...localConfig.options];
newOptions[index] = { ...newOptions[index], disabled: !checked };
const newConfig = { ...localConfig, options: newOptions };
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
}}
/>
<Button size="sm" variant="destructive" onClick={() => removeOption(index)} className="p-1 text-xs">
<Trash2 className="h-3 w-3" />

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react";
@ -34,6 +34,17 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
const [isOpen, setIsOpen] = useState(false);
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectingType, setSelectingType] = useState<"from" | "to">("from");
// 로컬 임시 상태 (확인 버튼 누르기 전까지 임시 저장)
const [tempValue, setTempValue] = useState<DateRangeValue>(value || {});
// 팝오버가 열릴 때 현재 값으로 초기화
useEffect(() => {
if (isOpen) {
setTempValue(value || {});
setSelectingType("from");
}
}, [isOpen, value]);
const formatDate = (date: Date | undefined) => {
if (!date) return "";
@ -57,26 +68,91 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
};
const handleDateClick = (date: Date) => {
// 로컬 상태만 업데이트 (onChange 호출 안 함)
if (selectingType === "from") {
const newValue = { ...value, from: date };
onChange(newValue);
setTempValue({ ...tempValue, from: date });
setSelectingType("to");
} else {
const newValue = { ...value, to: date };
onChange(newValue);
setTempValue({ ...tempValue, to: date });
setSelectingType("from");
}
};
const handleClear = () => {
onChange({});
setTempValue({});
setSelectingType("from");
};
const handleConfirm = () => {
// 확인 버튼을 눌렀을 때만 onChange 호출
onChange(tempValue);
setIsOpen(false);
setSelectingType("from");
};
const handleCancel = () => {
// 취소 시 임시 값 버리고 팝오버 닫기
setTempValue(value || {});
setIsOpen(false);
setSelectingType("from");
};
// 빠른 기간 선택 함수들 (즉시 적용 + 팝오버 닫기)
const setToday = () => {
const today = new Date();
const newValue = { from: today, to: today };
setTempValue(newValue);
onChange(newValue);
setIsOpen(false);
setSelectingType("from");
};
const setThisWeek = () => {
const today = new Date();
const dayOfWeek = today.getDay();
const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; // 월요일 기준
const monday = new Date(today);
monday.setDate(today.getDate() + diff);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
const newValue = { from: monday, to: sunday };
setTempValue(newValue);
onChange(newValue);
setIsOpen(false);
setSelectingType("from");
};
const setThisMonth = () => {
const today = new Date();
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0);
const newValue = { from: firstDay, to: lastDay };
setTempValue(newValue);
onChange(newValue);
setIsOpen(false);
setSelectingType("from");
};
const setLast7Days = () => {
const today = new Date();
const sevenDaysAgo = new Date(today);
sevenDaysAgo.setDate(today.getDate() - 6);
const newValue = { from: sevenDaysAgo, to: today };
setTempValue(newValue);
onChange(newValue);
setIsOpen(false);
setSelectingType("from");
};
const setLast30Days = () => {
const today = new Date();
const thirtyDaysAgo = new Date(today);
thirtyDaysAgo.setDate(today.getDate() - 29);
const newValue = { from: thirtyDaysAgo, to: today };
setTempValue(newValue);
onChange(newValue);
setIsOpen(false);
setSelectingType("from");
// 날짜는 이미 선택 시점에 onChange가 호출되므로 중복 호출 제거
};
const monthStart = startOfMonth(currentMonth);
@ -91,16 +167,16 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
const allDays = [...Array(paddingDays).fill(null), ...days];
const isInRange = (date: Date) => {
if (!value.from || !value.to) return false;
return date >= value.from && date <= value.to;
if (!tempValue.from || !tempValue.to) return false;
return date >= tempValue.from && date <= tempValue.to;
};
const isRangeStart = (date: Date) => {
return value.from && isSameDay(date, value.from);
return tempValue.from && isSameDay(date, tempValue.from);
};
const isRangeEnd = (date: Date) => {
return value.to && isSameDay(date, value.to);
return tempValue.to && isSameDay(date, tempValue.to);
};
return (
@ -127,6 +203,25 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
</div>
</div>
{/* 빠른 선택 버튼 */}
<div className="mb-4 flex flex-wrap gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={setToday}>
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={setThisWeek}>
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={setThisMonth}>
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={setLast7Days}>
7
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={setLast30Days}>
30
</Button>
</div>
{/* 월 네비게이션 */}
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
@ -183,13 +278,13 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
</div>
{/* 선택된 범위 표시 */}
{(value.from || value.to) && (
{(tempValue.from || tempValue.to) && (
<div className="bg-muted mb-4 rounded-md p-2">
<div className="text-muted-foreground mb-1 text-xs"> </div>
<div className="text-sm">
{value.from && <span className="font-medium">: {formatDate(value.from)}</span>}
{value.from && value.to && <span className="mx-2">~</span>}
{value.to && <span className="font-medium">: {formatDate(value.to)}</span>}
{tempValue.from && <span className="font-medium">: {formatDate(tempValue.from)}</span>}
{tempValue.from && tempValue.to && <span className="mx-2">~</span>}
{tempValue.to && <span className="font-medium">: {formatDate(tempValue.to)}</span>}
</div>
</div>
)}
@ -200,7 +295,7 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
</Button>
<div className="space-x-2">
<Button variant="outline" size="sm" onClick={() => setIsOpen(false)}>
<Button variant="outline" size="sm" onClick={handleCancel}>
</Button>
<Button size="sm" onClick={handleConfirm}>

View File

@ -78,3 +78,4 @@ export const numberingRuleTemplate = {

View File

@ -11,9 +11,10 @@ interface TabsWidgetProps {
component: TabsComponent;
className?: string;
style?: React.CSSProperties;
menuObjid?: number; // 🆕 부모 화면의 메뉴 OBJID
}
export function TabsWidget({ component, className, style }: TabsWidgetProps) {
export function TabsWidget({ component, className, style, menuObjid }: TabsWidgetProps) {
const {
tabs = [],
defaultTab,
@ -233,6 +234,11 @@ export function TabsWidget({ component, className, style }: TabsWidgetProps) {
key={component.id}
component={component}
allComponents={components}
screenInfo={{
id: tab.screenId,
tableName: layoutData.tableName,
}}
menuObjid={menuObjid} // 🆕 부모의 menuObjid 전달
/>
))}
</div>

View File

@ -122,10 +122,6 @@ const ResizableDialogContent = React.forwardRef<
// 1순위: userStyle에서 크기 추출 (화면관리에서 지정한 크기 - 항상 초기값으로 사용)
if (userStyle) {
console.log("🔍 userStyle 감지:", userStyle);
console.log("🔍 userStyle.width 타입:", typeof userStyle.width, "값:", userStyle.width);
console.log("🔍 userStyle.height 타입:", typeof userStyle.height, "값:", userStyle.height);
const styleWidth = typeof userStyle.width === 'string'
? parseInt(userStyle.width)
: userStyle.width;
@ -133,31 +129,15 @@ const ResizableDialogContent = React.forwardRef<
? parseInt(userStyle.height)
: userStyle.height;
console.log("📏 파싱된 크기:", {
styleWidth,
styleHeight,
"styleWidth truthy?": !!styleWidth,
"styleHeight truthy?": !!styleHeight,
minWidth,
maxWidth,
minHeight,
maxHeight
});
if (styleWidth && styleHeight) {
const finalSize = {
width: Math.max(minWidth, Math.min(maxWidth, styleWidth)),
height: Math.max(minHeight, Math.min(maxHeight, styleHeight)),
};
console.log("✅ userStyle 크기 사용:", finalSize);
return finalSize;
} else {
console.log("❌ styleWidth 또는 styleHeight가 falsy:", { styleWidth, styleHeight });
}
}
console.log("⚠️ userStyle 없음, defaultWidth/defaultHeight 사용:", { defaultWidth, defaultHeight });
// 2순위: 현재 렌더링된 크기 사용 (주석처리 - 모달이 열린 후 늘어나는 현상 방지)
// if (contentRef.current) {
// const rect = contentRef.current.getBoundingClientRect();
@ -209,7 +189,6 @@ const ResizableDialogContent = React.forwardRef<
// 사용자가 리사이징한 크기 우선
setSize({ width: savedSize.width, height: savedSize.height });
setUserResized(true);
console.log("✅ 사용자 리사이징 크기 적용:", savedSize);
} else if (userStyle && userStyle.width && userStyle.height) {
// 화면관리에서 설정한 크기
const styleWidth = typeof userStyle.width === 'string'
@ -224,7 +203,6 @@ const ResizableDialogContent = React.forwardRef<
width: Math.max(minWidth, Math.min(maxWidth, styleWidth)),
height: Math.max(minHeight, Math.min(maxHeight, styleHeight)),
};
console.log("🔄 userStyle 크기 적용:", newSize);
setSize(newSize);
}
}

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useMemo } from "react";
import React, { useState, useMemo, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@ -34,6 +34,21 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
}) => {
const [localFields, setLocalFields] = useState<RepeaterFieldDefinition[]>(config.fields || []);
const [fieldNamePopoverOpen, setFieldNamePopoverOpen] = useState<Record<number, boolean>>({});
// 로컬 입력 상태 (각 필드의 라벨, placeholder 등)
const [localInputs, setLocalInputs] = useState<Record<number, { label: string; placeholder: string }>>({});
// 설정 입력 필드의 로컬 상태
const [localConfigInputs, setLocalConfigInputs] = useState({
addButtonText: config.addButtonText || "",
});
// config 변경 시 로컬 상태 동기화
useEffect(() => {
setLocalConfigInputs({
addButtonText: config.addButtonText || "",
});
}, [config.addButtonText]);
// 이미 사용된 컬럼명 목록
const usedColumnNames = useMemo(() => {
@ -72,7 +87,32 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
handleFieldsChange(localFields.filter((_, i) => i !== index));
};
// 필드 수정
// 필드 수정 (입력 중 - 로컬 상태만)
const updateFieldLocal = (index: number, field: 'label' | 'placeholder', value: string) => {
setLocalInputs(prev => ({
...prev,
[index]: {
...prev[index],
[field]: value
}
}));
};
// 필드 수정 완료 (onBlur - 실제 업데이트)
const handleFieldBlur = (index: number) => {
const localInput = localInputs[index];
if (localInput) {
const newFields = [...localFields];
newFields[index] = {
...newFields[index],
label: localInput.label,
placeholder: localInput.placeholder
};
handleFieldsChange(newFields);
}
};
// 필드 수정 (즉시 반영 - 드롭다운, 체크박스 등)
const updateField = (index: number, updates: Partial<RepeaterFieldDefinition>) => {
const newFields = [...localFields];
newFields[index] = { ...newFields[index], ...updates };
@ -157,7 +197,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Label className="text-sm font-semibold"> </Label>
{localFields.map((field, index) => (
<Card key={index} className="border-2">
<Card key={`${field.name}-${index}`} className="border-2">
<CardContent className="space-y-3 pt-4">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-700"> {index + 1}</span>
@ -200,6 +240,14 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
label: column.columnLabel || column.columnName,
type: (column.widgetType as RepeaterFieldType) || "text",
});
// 로컬 입력 상태도 업데이트
setLocalInputs(prev => ({
...prev,
[index]: {
label: column.columnLabel || column.columnName,
placeholder: prev[index]?.placeholder || ""
}
}));
setFieldNamePopoverOpen({ ...fieldNamePopoverOpen, [index]: false });
}}
className="text-xs"
@ -225,8 +273,9 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={field.label}
onChange={(e) => updateField(index, { label: e.target.value })}
value={localInputs[index]?.label !== undefined ? localInputs[index].label : field.label}
onChange={(e) => updateFieldLocal(index, 'label', e.target.value)}
onBlur={() => handleFieldBlur(index)}
placeholder="필드 라벨"
className="h-8 w-full text-xs"
/>
@ -258,10 +307,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<div className="space-y-1">
<Label className="text-xs">Placeholder</Label>
<Input
value={field.placeholder || ""}
onChange={(e) => updateField(index, { placeholder: e.target.value })}
value={localInputs[index]?.placeholder !== undefined ? localInputs[index].placeholder : (field.placeholder || "")}
onChange={(e) => updateFieldLocal(index, 'placeholder', e.target.value)}
onBlur={() => handleFieldBlur(index)}
placeholder="입력 안내"
className="h-8 w-full"
className="h-8 w-full text-xs"
/>
</div>
</div>
@ -329,8 +379,9 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Input
id="repeater-add-button-text"
type="text"
value={config.addButtonText || ""}
onChange={(e) => handleChange("addButtonText", e.target.value)}
value={localConfigInputs.addButtonText}
onChange={(e) => setLocalConfigInputs({ ...localConfigInputs, addButtonText: e.target.value })}
onBlur={() => handleChange("addButtonText", localConfigInputs.addButtonText)}
placeholder="항목 추가"
className="h-8"
/>

View File

@ -20,6 +20,25 @@ export async function getCategoryColumns(tableName: string) {
}
}
/**
*
*
* @param menuObjid OBJID
* @returns
*/
export async function getCategoryColumnsByMenu(menuObjid: number) {
try {
const response = await apiClient.get<{
success: boolean;
data: CategoryColumn[];
}>(`/table-management/menu/${menuObjid}/category-columns`);
return response.data;
} catch (error: any) {
console.error("메뉴별 카테고리 컬럼 조회 실패:", error);
return { success: false, error: error.message };
}
}
/**
* ( )
*

View File

@ -289,17 +289,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화
let currentValue;
if (componentType === "modal-repeater-table") {
// 🆕 EditModal에서 전달된 groupedData가 있으면 우선 사용
// EditModal에서 전달된 groupedData가 있으면 우선 사용
currentValue = props.groupedData || formData?.[fieldName] || [];
// 디버깅 로그
console.log("🔍 [DynamicComponentRenderer] ModalRepeaterTable value 설정:", {
hasGroupedData: !!props.groupedData,
groupedDataLength: props.groupedData?.length || 0,
fieldName,
formDataValue: formData?.[fieldName],
finalValueLength: Array.isArray(currentValue) ? currentValue.length : 0,
});
} else {
currentValue = formData?.[fieldName] || "";
}

View File

@ -13,8 +13,6 @@ import { ConditionalContainerProps, ConditionalSection } from "./types";
import { ConditionalSectionViewer } from "./ConditionalSectionViewer";
import { cn } from "@/lib/utils";
console.log("🚀 ConditionalContainerComponent 모듈 로드됨!");
/**
*
* UI를
@ -43,11 +41,6 @@ export function ConditionalContainerComponent({
groupedData, // 🆕 그룹 데이터
onSave, // 🆕 EditModal의 handleSave 콜백
}: ConditionalContainerProps) {
console.log("🎯 ConditionalContainerComponent 렌더링!", {
isDesignMode,
hasOnHeightChange: !!onHeightChange,
componentId,
});
// config prop 우선, 없으면 개별 prop 사용
const controlField = config?.controlField || propControlField || "condition";
@ -86,24 +79,8 @@ export function ConditionalContainerComponent({
const containerRef = useRef<HTMLDivElement>(null);
const previousHeightRef = useRef<number>(0);
// 🔍 디버그: props 확인
useEffect(() => {
console.log("🔍 ConditionalContainer props:", {
isDesignMode,
hasOnHeightChange: !!onHeightChange,
componentId,
selectedValue,
});
}, [isDesignMode, onHeightChange, componentId, selectedValue]);
// 높이 변화 감지 및 콜백 호출
useEffect(() => {
console.log("🔍 ResizeObserver 등록 조건:", {
hasContainer: !!containerRef.current,
isDesignMode,
hasOnHeightChange: !!onHeightChange,
});
if (!containerRef.current || isDesignMode || !onHeightChange) return;
const resizeObserver = new ResizeObserver((entries) => {

View File

@ -195,17 +195,69 @@ export function ModalRepeaterTableComponent({
const columnName = component?.columnName;
const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
// ✅ onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출)
// ✅ onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출 + 납기일 일괄 적용)
const handleChange = (newData: any[]) => {
// 🆕 납기일 일괄 적용 로직 (납기일 필드가 있는 경우만)
let processedData = newData;
// 납기일 필드 찾기 (item_due_date, delivery_date, due_date 등)
const dateField = columns.find(
(col) =>
col.field === "item_due_date" ||
col.field === "delivery_date" ||
col.field === "due_date"
);
if (dateField && !isDeliveryDateApplied && newData.length > 0) {
// 현재 상태: 납기일이 있는 행과 없는 행 개수 체크
const itemsWithDate = newData.filter((item) => item[dateField.field]);
const itemsWithoutDate = newData.filter((item) => !item[dateField.field]);
// 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때 일괄 적용
if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) {
const selectedDate = itemsWithDate[0][dateField.field];
processedData = newData.map((item) => ({
...item,
[dateField.field]: selectedDate, // 모든 행에 동일한 납기일 적용
}));
setIsDeliveryDateApplied(true); // 플래그 활성화
}
}
// 🆕 수주일 일괄 적용 로직 (order_date 필드가 있는 경우만)
const orderDateField = columns.find(
(col) =>
col.field === "order_date" ||
col.field === "ordered_date"
);
if (orderDateField && !isOrderDateApplied && newData.length > 0) {
// ⚠️ 중요: 원본 newData를 참조해야 납기일의 영향을 받지 않음
const itemsWithOrderDate = newData.filter((item) => item[orderDateField.field]);
const itemsWithoutOrderDate = newData.filter((item) => !item[orderDateField.field]);
// ✅ 조건: 모든 행이 비어있는 초기 상태 → 어느 행에서든 첫 선택 시 전체 적용
if (itemsWithOrderDate.length === 1 && itemsWithoutOrderDate.length === newData.length - 1) {
const selectedOrderDate = itemsWithOrderDate[0][orderDateField.field];
processedData = processedData.map((item) => ({
...item,
[orderDateField.field]: selectedOrderDate,
}));
setIsOrderDateApplied(true); // 플래그 활성화
}
}
// 기존 onChange 콜백 호출 (호환성)
const externalOnChange = componentConfig?.onChange || propOnChange;
if (externalOnChange) {
externalOnChange(newData);
externalOnChange(processedData);
}
// 🆕 onFormDataChange 호출하여 EditModal의 groupData 업데이트
if (onFormDataChange && columnName) {
onFormDataChange(columnName, newData);
onFormDataChange(columnName, processedData);
}
};
@ -219,18 +271,22 @@ export function ModalRepeaterTableComponent({
const companyCode = componentConfig?.companyCode || propCompanyCode;
const [modalOpen, setModalOpen] = useState(false);
// 🆕 납기일 일괄 적용 플래그 (딱 한 번만 실행)
const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false);
// 🆕 수주일 일괄 적용 플래그 (딱 한 번만 실행)
const [isOrderDateApplied, setIsOrderDateApplied] = useState(false);
// columns가 비어있으면 sourceColumns로부터 자동 생성
const columns = React.useMemo((): RepeaterColumnConfig[] => {
const configuredColumns = componentConfig?.columns || propColumns || [];
if (configuredColumns.length > 0) {
console.log("✅ 설정된 columns 사용:", configuredColumns);
return configuredColumns;
}
// columns가 비어있으면 sourceColumns로부터 자동 생성
if (sourceColumns.length > 0) {
console.log("🔄 sourceColumns로부터 자동 생성:", sourceColumns);
const autoColumns: RepeaterColumnConfig[] = sourceColumns.map((field) => ({
field: field,
label: field, // 필드명을 라벨로 사용 (나중에 설정에서 변경 가능)
@ -238,99 +294,72 @@ export function ModalRepeaterTableComponent({
type: "text" as const,
width: "150px",
}));
console.log("📋 자동 생성된 columns:", autoColumns);
return autoColumns;
}
console.warn("⚠️ columns와 sourceColumns 모두 비어있음!");
console.warn("⚠️ [ModalRepeaterTable] columns와 sourceColumns 모두 비어있음!");
return [];
}, [componentConfig?.columns, propColumns, sourceColumns]);
// 초기 props 로깅
// 초기 props 검증
useEffect(() => {
if (rawSourceColumns.length !== sourceColumns.length) {
console.warn(`⚠️ sourceColumns 필터링: ${rawSourceColumns.length}개 → ${sourceColumns.length} (빈 문자열 제거)`);
console.warn(`⚠️ [ModalRepeaterTable] sourceColumns 필터링: ${rawSourceColumns.length}개 → ${sourceColumns.length}`);
}
if (rawUniqueField !== uniqueField) {
console.warn(`⚠️ uniqueField 자동 보정: "${rawUniqueField}" → "${uniqueField}"`);
console.warn(`⚠️ [ModalRepeaterTable] uniqueField 자동 보정: "${rawUniqueField}" → "${uniqueField}"`);
}
console.log("🎬 ModalRepeaterTableComponent 마운트:", {
columnsLength: columns.length,
sourceTable,
sourceColumns,
uniqueField,
});
if (columns.length === 0) {
console.error("❌ columns가 비어있습니다! sourceColumns:", sourceColumns);
} else {
console.log("✅ columns 설정 완료:", columns.map(c => c.label || c.field).join(", "));
console.error("❌ [ModalRepeaterTable] columns가 비어있습니다!", { sourceColumns });
}
}, []);
// value 변경 감지
useEffect(() => {
console.log("📦 ModalRepeaterTableComponent value 변경:", {
valueLength: value.length,
});
}, [value]);
// 🆕 저장 요청 시에만 데이터 전달 (beforeFormSave 이벤트 리스너)
useEffect(() => {
const handleSaveRequest = async (event: Event) => {
const componentKey = columnName || component?.id || "modal_repeater_data";
console.log("🔔 [ModalRepeaterTable] beforeFormSave 이벤트 수신!", {
componentKey,
itemsCount: value.length,
hasOnFormDataChange: !!onFormDataChange,
columnName,
componentId: component?.id,
targetTable,
});
if (value.length === 0) {
console.warn("⚠️ [ModalRepeaterTable] 저장할 데이터 없음");
return;
}
// 🔥 sourceColumns에 포함된 컬럼 제외 (조인된 컬럼 제거)
console.log("🔍 [ModalRepeaterTable] 필터링 전 데이터:", {
sourceColumns,
sourceTable,
targetTable,
sampleItem: value[0],
itemKeys: value[0] ? Object.keys(value[0]) : [],
});
// sourceColumns에 포함된 컬럼 제외 (조인된 컬럼 제거)
// 단, columnMappings에 정의된 컬럼은 저장해야 하므로 제외하지 않음
const mappedFields = columns
.filter(col => col.mapping?.type === "source" && col.mapping?.sourceField)
.map(col => col.field);
const filteredData = value.map((item: any) => {
const filtered: Record<string, any> = {};
Object.keys(item).forEach((key) => {
// sourceColumns에 포함된 컬럼은 제외 (item_info 테이블의 컬럼)
if (sourceColumns.includes(key)) {
console.log(`${key} 제외 (sourceColumn)`);
return;
}
// 메타데이터 필드도 제외
// 메타데이터 필드 제외
if (key.startsWith("_")) {
console.log(`${key} 제외 (메타데이터)`);
return;
}
// sourceColumns에 포함되어 있지만 columnMappings에도 정의된 경우 → 저장함
if (mappedFields.includes(key)) {
filtered[key] = item[key];
return;
}
// sourceColumns에만 있고 매핑 안 된 경우 → 제외 (조인 전용)
if (sourceColumns.includes(key)) {
return;
}
// 나머지는 모두 저장
filtered[key] = item[key];
});
return filtered;
});
console.log("✅ [ModalRepeaterTable] 필터링 후 데이터:", {
filteredItemKeys: filteredData[0] ? Object.keys(filteredData[0]) : [],
sampleFilteredItem: filteredData[0],
});
// 🔥 targetTable 메타데이터를 배열 항목에 추가
// targetTable 메타데이터를 배열 항목에 추가
const dataWithTargetTable = targetTable
? filteredData.map((item: any) => ({
...item,
@ -338,21 +367,19 @@ export function ModalRepeaterTableComponent({
}))
: filteredData;
// CustomEvent의 detail에 데이터 추가
// CustomEvent의 detail에 데이터 추가
if (event instanceof CustomEvent && event.detail) {
event.detail.formData[componentKey] = dataWithTargetTable;
console.log("✅ [ModalRepeaterTable] context.formData에 데이터 추가 완료:", {
console.log("✅ [ModalRepeaterTable] 저장 데이터 준비:", {
key: componentKey,
itemCount: dataWithTargetTable.length,
targetTable: targetTable || "미설정 (화면 설계에서 설정 필요)",
sampleItem: dataWithTargetTable[0],
targetTable: targetTable || "미설정",
});
}
// 기존 onFormDataChange도 호출 (호환성)
if (onFormDataChange) {
onFormDataChange(componentKey, dataWithTargetTable);
console.log("✅ [ModalRepeaterTable] onFormDataChange 호출 완료");
}
};

View File

@ -15,7 +15,8 @@ import { cn } from "@/lib/utils";
interface ModalRepeaterTableConfigPanelProps {
config: Partial<ModalRepeaterTableProps>;
onConfigChange: (config: Partial<ModalRepeaterTableProps>) => void;
onChange: (config: Partial<ModalRepeaterTableProps>) => void;
onConfigChange?: (config: Partial<ModalRepeaterTableProps>) => void; // 하위 호환성
}
// 소스 컬럼 선택기 (동적 테이블별 컬럼 로드)
@ -124,8 +125,11 @@ function ReferenceColumnSelector({
export function ModalRepeaterTableConfigPanel({
config,
onChange,
onConfigChange,
}: ModalRepeaterTableConfigPanelProps) {
// 하위 호환성: onConfigChange가 있으면 사용, 없으면 onChange 사용
const handleConfigChange = onConfigChange || onChange;
// 초기 설정 정리: 계산 규칙과 컬럼 설정 동기화
const cleanupInitialConfig = (initialConfig: Partial<ModalRepeaterTableProps>): Partial<ModalRepeaterTableProps> => {
// 계산 규칙이 없으면 모든 컬럼의 calculated 속성 제거
@ -241,7 +245,7 @@ export function ModalRepeaterTableConfigPanel({
const updateConfig = (updates: Partial<ModalRepeaterTableProps>) => {
const newConfig = { ...localConfig, ...updates };
setLocalConfig(newConfig);
onConfigChange(newConfig);
handleConfigChange(newConfig);
};
const addSourceColumn = () => {

View File

@ -15,14 +15,14 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { getSecondLevelMenus, getCategoryColumns, getCategoryValues } from "@/lib/api/tableCategoryValue";
import { getSecondLevelMenus, getCategoryColumns, getCategoryColumnsByMenu, getCategoryValues } from "@/lib/api/tableCategoryValue";
import { CalculationBuilder } from "./CalculationBuilder";
export interface SelectedItemsDetailInputConfigPanelProps {
config: SelectedItemsDetailInputConfig;
onChange: (config: Partial<SelectedItemsDetailInputConfig>) => void;
sourceTableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string }>; // 🆕 원본 테이블 컬럼
targetTableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string }>; // 🆕 대상 테이블 컬럼
sourceTableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string }>; // 🆕 원본 테이블 컬럼 (inputType 추가)
targetTableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string; codeCategory?: string }>; // 🆕 대상 테이블 컬럼 (inputType, codeCategory 추가)
allTables?: Array<{ tableName: string; displayName?: string }>;
screenTableName?: string; // 🆕 현재 화면의 테이블명 (자동 설정용)
onSourceTableChange?: (tableName: string) => void; // 🆕 원본 테이블 변경 콜백
@ -50,6 +50,18 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
// 🆕 필드 그룹 상태
const [localFieldGroups, setLocalFieldGroups] = useState<FieldGroup[]>(config.fieldGroups || []);
// 🆕 그룹 입력값을 위한 로컬 상태 (포커스 유지용)
const [localGroupInputs, setLocalGroupInputs] = useState<Record<string, { id?: string; title?: string; description?: string; order?: number }>>({});
// 🆕 필드 입력값을 위한 로컬 상태 (포커스 유지용)
const [localFieldInputs, setLocalFieldInputs] = useState<Record<number, { label?: string; placeholder?: string }>>({});
// 🆕 표시 항목의 입력값을 위한 로컬 상태 (포커스 유지용)
const [localDisplayItemInputs, setLocalDisplayItemInputs] = useState<Record<string, Record<number, { label?: string; value?: string }>>>({});
// 🆕 부모 데이터 매핑의 기본값 입력을 위한 로컬 상태 (포커스 유지용)
const [localMappingInputs, setLocalMappingInputs] = useState<Record<number, string>>({});
// 🆕 그룹별 펼침/접힘 상태
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
@ -57,6 +69,13 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
// 🆕 그룹별 표시 항목 설정 펼침/접힘 상태
const [expandedDisplayItems, setExpandedDisplayItems] = useState<Record<string, boolean>>({});
// 🆕 카테고리 매핑 아코디언 펼침/접힘 상태
const [expandedCategoryMappings, setExpandedCategoryMappings] = useState<Record<string, boolean>>({
discountType: false,
roundingType: false,
roundingUnit: false,
});
// 🆕 원본 테이블 선택 상태
const [sourceTableSelectOpen, setSourceTableSelectOpen] = useState(false);
const [sourceTableSearchValue, setSourceTableSearchValue] = useState("");
@ -77,8 +96,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
const [autoFillTableColumns, setAutoFillTableColumns] = useState<Record<number, Array<{ columnName: string; columnLabel?: string; dataType?: string }>>>({});
// 🆕 원본/대상 테이블 컬럼 상태 (내부에서 로드)
const [loadedSourceTableColumns, setLoadedSourceTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string }>>([]);
const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string }>>([]);
const [loadedSourceTableColumns, setLoadedSourceTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string }>>([]);
const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string; codeCategory?: string }>>([]);
// 🆕 원본 테이블 컬럼 로드
useEffect(() => {
@ -99,6 +118,7 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
columnName: col.columnName,
columnLabel: col.displayName || col.columnLabel || col.columnName,
dataType: col.dataType,
inputType: col.inputType, // 🔧 inputType 추가
})));
console.log("✅ 원본 테이블 컬럼 로드 성공:", columns.length);
}
@ -129,6 +149,7 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
columnName: col.columnName,
columnLabel: col.displayName || col.columnLabel || col.columnName,
dataType: col.dataType,
inputType: col.inputType, // 🔧 inputType 추가
})));
console.log("✅ 대상 테이블 컬럼 로드 성공:", columns.length);
}
@ -140,6 +161,39 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
loadColumns();
}, [config.targetTable]);
// 🆕 필드 그룹 변경 시 로컬 입력 상태 동기화
useEffect(() => {
setLocalFieldGroups(config.fieldGroups || []);
// 로컬 입력 상태는 기존 값 보존하면서 새 그룹만 추가
setLocalGroupInputs(prev => {
const newInputs = { ...prev };
(config.fieldGroups || []).forEach(group => {
if (!(group.id in newInputs)) {
newInputs[group.id] = {
id: group.id,
title: group.title,
description: group.description,
order: group.order,
};
}
});
return newInputs;
});
// 🔧 표시 항목이 있는 그룹은 아코디언을 열린 상태로 초기화
setExpandedDisplayItems(prev => {
const newExpanded = { ...prev };
(config.fieldGroups || []).forEach(group => {
// 이미 상태가 있으면 유지, 없으면 displayItems가 있을 때만 열기
if (!(group.id in newExpanded) && group.displayItems && group.displayItems.length > 0) {
newExpanded[group.id] = true;
}
});
return newExpanded;
});
}, [config.fieldGroups]);
// 🆕 초기 렌더링 시 기존 필드들의 autoFillFromTable 컬럼 로드
useEffect(() => {
if (!localFields || localFields.length === 0) return;
@ -211,6 +265,36 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
}
};
// 🆕 저장된 부모 데이터 매핑의 컬럼 자동 로드
useEffect(() => {
const loadSavedMappingColumns = async () => {
if (!config.parentDataMapping || config.parentDataMapping.length === 0) {
console.log("📭 [부모 데이터 매핑] 매핑이 없습니다");
return;
}
console.log("🔍 [부모 데이터 매핑] 저장된 매핑 컬럼 자동 로드 시작:", config.parentDataMapping.length);
for (let i = 0; i < config.parentDataMapping.length; i++) {
const mapping = config.parentDataMapping[i];
// 이미 로드된 컬럼이 있으면 스킵
if (mappingSourceColumns[i] && mappingSourceColumns[i].length > 0) {
console.log(`⏭️ [매핑 ${i}] 이미 로드된 컬럼이 있음`);
continue;
}
// 소스 테이블이 선택되어 있으면 컬럼 로드
if (mapping.sourceTable) {
console.log(`📡 [매핑 ${i}] 소스 테이블 컬럼 자동 로드:`, mapping.sourceTable);
await loadMappingSourceColumns(mapping.sourceTable, i);
}
}
};
loadSavedMappingColumns();
}, [config.parentDataMapping]);
// 2레벨 메뉴 목록 로드
useEffect(() => {
const loadMenus = async () => {
@ -224,26 +308,39 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
// 메뉴 선택 시 카테고리 목록 로드
const handleMenuSelect = async (menuObjid: number, fieldType: "discountType" | "roundingType" | "roundingUnit") => {
if (!config.targetTable) {
console.warn("⚠️ targetTable이 설정되지 않았습니다");
return;
}
console.log("🔍 [handleMenuSelect] 시작", { menuObjid, fieldType });
console.log("🔍 카테고리 목록 로드 시작", { targetTable: config.targetTable, menuObjid, fieldType });
// 🔧 1단계: 아코디언 먼저 열기 (리렌더링 전에)
setExpandedCategoryMappings(prev => {
const newState = { ...prev, [fieldType]: true };
console.log("🔄 [handleMenuSelect] 아코디언 열기:", newState);
return newState;
});
const response = await getCategoryColumns(config.targetTable);
// 🔧 2단계: 메뉴별 카테고리 컬럼 API 호출
const response = await getCategoryColumnsByMenu(menuObjid);
console.log("📥 getCategoryColumns 응답:", response);
console.log("📥 [handleMenuSelect] API 응답:", response);
if (response.success && response.data) {
console.log("✅ 카테고리 컬럼 데이터:", response.data);
setCategoryColumns(prev => ({ ...prev, [fieldType]: response.data }));
console.log("✅ [handleMenuSelect] 카테고리 컬럼 데이터:", {
fieldType,
columns: response.data,
count: response.data.length
});
// 카테고리 컬럼 상태 업데이트
setCategoryColumns(prev => {
const newState = { ...prev, [fieldType]: response.data };
console.log("🔄 [handleMenuSelect] categoryColumns 업데이트:", newState);
return newState;
});
} else {
console.error("❌ 카테고리 컬럼 로드 실패:", response);
console.error("❌ [handleMenuSelect] 카테고리 컬럼 로드 실패:", response);
}
// valueMapping 업데이트
handleChange("autoCalculation", {
// 🔧 3단계: valueMapping 업데이트 (마지막에)
const newConfig = {
...config.autoCalculation,
valueMapping: {
...config.autoCalculation.valueMapping,
@ -252,20 +349,50 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
[fieldType]: menuObjid,
},
},
});
};
console.log("🔄 [handleMenuSelect] valueMapping 업데이트:", newConfig);
handleChange("autoCalculation", newConfig);
};
// 카테고리 선택 시 카테고리 값 목록 로드
const handleCategorySelect = async (columnName: string, menuObjid: number, fieldType: "discountType" | "roundingType" | "roundingUnit") => {
if (!config.targetTable) return;
console.log("🔍 [handleCategorySelect] 시작", { columnName, menuObjid, fieldType, targetTable: config.targetTable });
const response = await getCategoryValues(config.targetTable, columnName, false, menuObjid);
if (response.success && response.data) {
setCategoryValues(prev => ({ ...prev, [fieldType]: response.data }));
if (!config.targetTable) {
console.warn("⚠️ [handleCategorySelect] targetTable이 없습니다");
return;
}
const response = await getCategoryValues(config.targetTable, columnName, false, menuObjid);
console.log("📥 [handleCategorySelect] API 응답:", response);
if (response.success && response.data) {
console.log("✅ [handleCategorySelect] 카테고리 값 데이터:", {
fieldType,
values: response.data,
count: response.data.length
});
setCategoryValues(prev => {
const newState = { ...prev, [fieldType]: response.data };
console.log("🔄 [handleCategorySelect] categoryValues 업데이트:", newState);
return newState;
});
} else {
console.error("❌ [handleCategorySelect] 카테고리 값 로드 실패:", response);
}
// 🔧 카테고리 선택 시 아코디언 열기 (이미 열려있을 수도 있음)
setExpandedCategoryMappings(prev => {
const newState = { ...prev, [fieldType]: true };
console.log("🔄 [handleCategorySelect] 아코디언 상태:", newState);
return newState;
});
// valueMapping 업데이트
handleChange("autoCalculation", {
const newConfig = {
...config.autoCalculation,
valueMapping: {
...config.autoCalculation.valueMapping,
@ -274,9 +401,99 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
[fieldType]: columnName,
},
},
});
};
console.log("🔄 [handleCategorySelect] valueMapping 업데이트:", newConfig);
handleChange("autoCalculation", newConfig);
};
// 🆕 저장된 설정에서 카테고리 정보 복원
useEffect(() => {
const loadSavedCategories = async () => {
console.log("🔍 [loadSavedCategories] useEffect 실행", {
hasTargetTable: !!config.targetTable,
hasAutoCalc: !!config.autoCalculation,
hasValueMapping: !!config.autoCalculation?.valueMapping
});
if (!config.targetTable || !config.autoCalculation?.valueMapping) {
console.warn("⚠️ [loadSavedCategories] targetTable 또는 valueMapping이 없어 종료");
return;
}
const savedMenus = (config.autoCalculation.valueMapping as any)?._selectedMenus;
const savedCategories = (config.autoCalculation.valueMapping as any)?._selectedCategories;
console.log("🔄 [loadSavedCategories] 저장된 카테고리 설정 복원 시작:", { savedMenus, savedCategories });
// 각 필드 타입별로 저장된 카테고리 값 로드
const fieldTypes: Array<"discountType" | "roundingType" | "roundingUnit"> = ["discountType", "roundingType", "roundingUnit"];
// 🔧 복원할 아코디언 상태 준비
const newExpandedState: Record<string, boolean> = {};
for (const fieldType of fieldTypes) {
const menuObjid = savedMenus?.[fieldType];
const columnName = savedCategories?.[fieldType];
console.log(`🔍 [loadSavedCategories] ${fieldType} 처리`, { menuObjid, columnName });
// 🔧 메뉴만 선택된 경우에도 카테고리 컬럼 로드
if (menuObjid) {
console.log(`✅ [loadSavedCategories] ${fieldType} 메뉴 발견, 카테고리 컬럼 로드 시작:`, { menuObjid });
// 🔧 메뉴가 선택되어 있으면 아코디언 열기
newExpandedState[fieldType] = true;
// 🔧 메뉴별 카테고리 컬럼 로드 (카테고리 선택 여부와 무관)
console.log(`📡 [loadSavedCategories] ${fieldType} 카테고리 컬럼 API 호출`, { menuObjid });
const columnsResponse = await getCategoryColumnsByMenu(menuObjid);
console.log(`📥 [loadSavedCategories] ${fieldType} 컬럼 응답:`, columnsResponse);
if (columnsResponse.success && columnsResponse.data) {
setCategoryColumns(prev => {
const newState = { ...prev, [fieldType]: columnsResponse.data };
console.log(`🔄 [loadSavedCategories] ${fieldType} categoryColumns 업데이트:`, newState);
return newState;
});
} else {
console.error(`❌ [loadSavedCategories] ${fieldType} 컬럼 로드 실패:`, columnsResponse);
}
// 🔧 카테고리까지 선택된 경우에만 값 로드
if (columnName) {
console.log(`📡 [loadSavedCategories] ${fieldType} 카테고리 값 API 호출`, { columnName });
const valuesResponse = await getCategoryValues(config.targetTable, columnName, false, menuObjid);
console.log(`📥 [loadSavedCategories] ${fieldType} 값 응답:`, valuesResponse);
if (valuesResponse.success && valuesResponse.data) {
console.log(`✅ [loadSavedCategories] ${fieldType} 카테고리 값:`, valuesResponse.data);
setCategoryValues(prev => {
const newState = { ...prev, [fieldType]: valuesResponse.data };
console.log(`🔄 [loadSavedCategories] ${fieldType} categoryValues 업데이트:`, newState);
return newState;
});
} else {
console.error(`❌ [loadSavedCategories] ${fieldType} 값 로드 실패:`, valuesResponse);
}
}
}
}
// 🔧 저장된 설정이 있는 아코디언들 열기
if (Object.keys(newExpandedState).length > 0) {
console.log("🔓 [loadSavedCategories] 아코디언 열기:", newExpandedState);
setExpandedCategoryMappings(prev => {
const finalState = { ...prev, ...newExpandedState };
console.log("🔄 [loadSavedCategories] 최종 아코디언 상태:", finalState);
return finalState;
});
}
};
loadSavedCategories();
}, [config.targetTable, config.autoCalculation?.valueMapping]);
// 🆕 초기 로드 시 screenTableName을 targetTable로 자동 설정
React.useEffect(() => {
if (screenTableName && !config.targetTable) {
@ -317,10 +534,37 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
// 필드 제거
const removeField = (index: number) => {
// 로컬 입력 상태에서도 제거
setLocalFieldInputs(prev => {
const newInputs = { ...prev };
delete newInputs[index];
return newInputs;
});
handleFieldsChange(localFields.filter((_, i) => i !== index));
};
// 필드 수정
// 🆕 로컬 필드 입력 업데이트 (포커스 유지용)
const updateFieldLocal = (index: number, field: 'label' | 'placeholder', value: string) => {
setLocalFieldInputs(prev => ({
...prev,
[index]: {
...prev[index],
[field]: value
}
}));
};
// 🆕 실제 필드 데이터 업데이트 (onBlur 시 호출)
const handleFieldBlur = (index: number) => {
const localInput = localFieldInputs[index];
if (localInput) {
const newFields = [...localFields];
newFields[index] = { ...newFields[index], ...localInput };
handleFieldsChange(newFields);
}
};
// 필드 수정 (Switch 같은 즉시 업데이트가 필요한 경우에만 사용)
const updateField = (index: number, updates: Partial<AdditionalFieldDefinition>) => {
const newFields = [...localFields];
newFields[index] = { ...newFields[index], ...updates };
@ -343,6 +587,13 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
};
const removeFieldGroup = (groupId: string) => {
// 로컬 입력 상태에서 해당 그룹 제거
setLocalGroupInputs(prev => {
const newInputs = { ...prev };
delete newInputs[groupId];
return newInputs;
});
// 그룹 삭제 시 해당 그룹에 속한 필드들의 groupId도 제거
const updatedFields = localFields.map(field =>
field.groupId === groupId ? { ...field, groupId: undefined } : field
@ -352,7 +603,30 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
handleFieldGroupsChange(localFieldGroups.filter(g => g.id !== groupId));
};
// 🆕 로컬 그룹 입력 업데이트 (포커스 유지용)
const updateGroupLocal = (groupId: string, field: 'id' | 'title' | 'description' | 'order', value: any) => {
setLocalGroupInputs(prev => ({
...prev,
[groupId]: {
...prev[groupId],
[field]: value
}
}));
};
// 🆕 실제 그룹 데이터 업데이트 (onBlur 시 호출)
const handleGroupBlur = (groupId: string) => {
const localInput = localGroupInputs[groupId];
if (localInput) {
const newGroups = localFieldGroups.map(g =>
g.id === groupId ? { ...g, ...localInput } : g
);
handleFieldGroupsChange(newGroups);
}
};
const updateFieldGroup = (groupId: string, updates: Partial<FieldGroup>) => {
// 2. 실제 그룹 데이터 업데이트 (Switch 같은 즉시 업데이트가 필요한 경우에만 사용)
const newGroups = localFieldGroups.map(g =>
g.id === groupId ? { ...g, ...updates } : g
);
@ -426,6 +700,12 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
return g;
});
// 🔧 아이템 추가 시 해당 그룹의 아코디언을 열린 상태로 유지
setExpandedDisplayItems(prev => ({
...prev,
[groupId]: true
}));
setLocalFieldGroups(updatedGroups);
handleChange("fieldGroups", updatedGroups);
};
@ -755,8 +1035,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs"></Label>
<Input
value={field.label}
onChange={(e) => updateField(index, { label: e.target.value })}
value={localFieldInputs[index]?.label !== undefined ? localFieldInputs[index].label : field.label}
onChange={(e) => updateFieldLocal(index, 'label', e.target.value)}
onBlur={() => handleFieldBlur(index)}
placeholder="필드 라벨"
className="h-6 w-full text-[10px] sm:h-7 sm:text-xs"
/>
@ -780,8 +1061,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs">Placeholder</Label>
<Input
value={field.placeholder || ""}
onChange={(e) => updateField(index, { placeholder: e.target.value })}
value={localFieldInputs[index]?.placeholder !== undefined ? localFieldInputs[index].placeholder : (field.placeholder || "")}
onChange={(e) => updateFieldLocal(index, 'placeholder', e.target.value)}
onBlur={() => handleFieldBlur(index)}
placeholder="입력 안내"
className="h-6 w-full text-[10px] sm:h-7 sm:text-xs"
/>
@ -1036,8 +1318,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs"> ID</Label>
<Input
value={group.id}
onChange={(e) => updateFieldGroup(group.id, { id: e.target.value })}
value={localGroupInputs[group.id]?.id !== undefined ? localGroupInputs[group.id].id : group.id}
onChange={(e) => updateGroupLocal(group.id, 'id', e.target.value)}
onBlur={() => handleGroupBlur(group.id)}
className="h-7 text-xs sm:h-8 sm:text-sm"
placeholder="group_customer"
/>
@ -1047,8 +1330,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs"> </Label>
<Input
value={group.title}
onChange={(e) => updateFieldGroup(group.id, { title: e.target.value })}
value={localGroupInputs[group.id]?.title !== undefined ? localGroupInputs[group.id].title : group.title}
onChange={(e) => updateGroupLocal(group.id, 'title', e.target.value)}
onBlur={() => handleGroupBlur(group.id)}
className="h-7 text-xs sm:h-8 sm:text-sm"
placeholder="거래처 정보"
/>
@ -1058,8 +1342,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs"> ()</Label>
<Input
value={group.description || ""}
onChange={(e) => updateFieldGroup(group.id, { description: e.target.value })}
value={localGroupInputs[group.id]?.description !== undefined ? localGroupInputs[group.id].description : (group.description || "")}
onChange={(e) => updateGroupLocal(group.id, 'description', e.target.value)}
onBlur={() => handleGroupBlur(group.id)}
className="h-7 text-xs sm:h-8 sm:text-sm"
placeholder="거래처 관련 정보를 입력합니다"
/>
@ -1070,8 +1355,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<Label className="text-[10px] sm:text-xs"> </Label>
<Input
type="number"
value={group.order || 0}
onChange={(e) => updateFieldGroup(group.id, { order: parseInt(e.target.value) || 0 })}
value={localGroupInputs[group.id]?.order !== undefined ? localGroupInputs[group.id].order : (group.order || 0)}
onChange={(e) => updateGroupLocal(group.id, 'order', parseInt(e.target.value) || 0)}
onBlur={() => handleGroupBlur(group.id)}
className="h-7 text-xs sm:h-8 sm:text-sm"
min="0"
/>
@ -1167,8 +1453,30 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
{/* 아이콘 설정 */}
{item.type === "icon" && (
<Input
value={item.icon || ""}
onChange={(e) => updateDisplayItemInGroup(group.id, itemIndex, { icon: e.target.value })}
value={
localDisplayItemInputs[group.id]?.[itemIndex]?.value !== undefined
? localDisplayItemInputs[group.id][itemIndex].value
: item.icon || ""
}
onChange={(e) => {
const newValue = e.target.value;
setLocalDisplayItemInputs(prev => ({
...prev,
[group.id]: {
...prev[group.id],
[itemIndex]: {
...prev[group.id]?.[itemIndex],
value: newValue
}
}
}));
}}
onBlur={() => {
const localValue = localDisplayItemInputs[group.id]?.[itemIndex]?.value;
if (localValue !== undefined) {
updateDisplayItemInGroup(group.id, itemIndex, { icon: localValue });
}
}}
placeholder="Building"
className="h-6 text-[9px] sm:text-[10px]"
/>
@ -1177,8 +1485,31 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
{/* 텍스트 설정 */}
{item.type === "text" && (
<Input
value={item.value || ""}
onChange={(e) => updateDisplayItemInGroup(group.id, itemIndex, { value: e.target.value })}
value={
localDisplayItemInputs[group.id]?.[itemIndex]?.value !== undefined
? localDisplayItemInputs[group.id][itemIndex].value
: item.value || ""
}
onChange={(e) => {
const newValue = e.target.value;
// 로컬 상태 즉시 업데이트 (포커스 유지)
setLocalDisplayItemInputs(prev => ({
...prev,
[group.id]: {
...prev[group.id],
[itemIndex]: {
...prev[group.id]?.[itemIndex],
value: newValue
}
}
}));
}}
onBlur={() => {
const localValue = localDisplayItemInputs[group.id]?.[itemIndex]?.value;
if (localValue !== undefined) {
updateDisplayItemInGroup(group.id, itemIndex, { value: localValue });
}
}}
placeholder="| , / , -"
className="h-6 text-[9px] sm:text-[10px]"
/>
@ -1206,8 +1537,31 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
{/* 라벨 */}
<Input
value={item.label || ""}
onChange={(e) => updateDisplayItemInGroup(group.id, itemIndex, { label: e.target.value })}
value={
localDisplayItemInputs[group.id]?.[itemIndex]?.label !== undefined
? localDisplayItemInputs[group.id][itemIndex].label
: item.label || ""
}
onChange={(e) => {
const newValue = e.target.value;
// 로컬 상태 즉시 업데이트 (포커스 유지)
setLocalDisplayItemInputs(prev => ({
...prev,
[group.id]: {
...prev[group.id],
[itemIndex]: {
...prev[group.id]?.[itemIndex],
label: newValue
}
}
}));
}}
onBlur={() => {
const localValue = localDisplayItemInputs[group.id]?.[itemIndex]?.label;
if (localValue !== undefined) {
updateDisplayItemInGroup(group.id, itemIndex, { label: localValue });
}
}}
placeholder="라벨 (예: 거래처:)"
className="h-6 w-full text-[9px] sm:text-[10px]"
/>
@ -1247,8 +1601,30 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
{/* 기본값 */}
{item.emptyBehavior === "default" && (
<Input
value={item.defaultValue || ""}
onChange={(e) => updateDisplayItemInGroup(group.id, itemIndex, { defaultValue: e.target.value })}
value={
localDisplayItemInputs[group.id]?.[itemIndex]?.value !== undefined
? localDisplayItemInputs[group.id][itemIndex].value
: item.defaultValue || ""
}
onChange={(e) => {
const newValue = e.target.value;
setLocalDisplayItemInputs(prev => ({
...prev,
[group.id]: {
...prev[group.id],
[itemIndex]: {
...prev[group.id]?.[itemIndex],
value: newValue
}
}
}));
}}
onBlur={() => {
const localValue = localDisplayItemInputs[group.id]?.[itemIndex]?.value;
if (localValue !== undefined) {
updateDisplayItemInGroup(group.id, itemIndex, { defaultValue: localValue });
}
}}
placeholder="미입력"
className="h-6 w-full text-[9px] sm:text-[10px]"
/>
@ -1563,14 +1939,21 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<Label className="text-[10px] font-semibold sm:text-xs"> </Label>
{/* 할인 방식 매핑 */}
<Collapsible>
<Collapsible
open={expandedCategoryMappings.discountType}
onOpenChange={(open) => setExpandedCategoryMappings(prev => ({ ...prev, discountType: open }))}
>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="flex w-full items-center justify-between p-2 hover:bg-muted"
>
<span className="text-xs font-medium"> </span>
<ChevronDown className="h-4 w-4" />
{expandedCategoryMappings.discountType ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
@ -1595,30 +1978,40 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
</div>
{/* 2단계: 카테고리 선택 */}
{(config.autoCalculation.valueMapping as any)?._selectedMenus?.discountType && (
<div className="space-y-1">
<Label className="text-[9px] sm:text-[10px]">2단계: 카테고리 </Label>
<Select
value={(config.autoCalculation.valueMapping as any)?._selectedCategories?.discountType || ""}
onValueChange={(value) => handleCategorySelect(
value,
(config.autoCalculation.valueMapping as any)._selectedMenus.discountType,
"discountType"
)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{(categoryColumns.discountType || []).map((col: any) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{(() => {
const hasSelectedMenu = !!(config.autoCalculation.valueMapping as any)?._selectedMenus?.discountType;
const columns = categoryColumns.discountType || [];
console.log("🎨 [렌더링] 2단계 카테고리 선택", {
hasSelectedMenu,
columns,
columnsCount: columns.length,
categoryColumnsState: categoryColumns
});
return hasSelectedMenu ? (
<div className="space-y-1">
<Label className="text-[9px] sm:text-[10px]">2단계: 카테고리 </Label>
<Select
value={(config.autoCalculation.valueMapping as any)?._selectedCategories?.discountType || ""}
onValueChange={(value) => handleCategorySelect(
value,
(config.autoCalculation.valueMapping as any)._selectedMenus.discountType,
"discountType"
)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col: any) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null;
})()}
{/* 3단계: 값 매핑 */}
{(config.autoCalculation.valueMapping as any)?._selectedCategories?.discountType && (
@ -1673,14 +2066,21 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
</Collapsible>
{/* 반올림 방식 매핑 */}
<Collapsible>
<Collapsible
open={expandedCategoryMappings.roundingType}
onOpenChange={(open) => setExpandedCategoryMappings(prev => ({ ...prev, roundingType: open }))}
>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="flex w-full items-center justify-between p-2 hover:bg-muted"
>
<span className="text-xs font-medium"> </span>
<ChevronDown className="h-4 w-4" />
{expandedCategoryMappings.roundingType ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
@ -1783,14 +2183,21 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
</Collapsible>
{/* 반올림 단위 매핑 */}
<Collapsible>
<Collapsible
open={expandedCategoryMappings.roundingUnit}
onOpenChange={(open) => setExpandedCategoryMappings(prev => ({ ...prev, roundingUnit: open }))}
>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="flex w-full items-center justify-between p-2 hover:bg-muted"
>
<span className="text-xs font-medium"> </span>
<ChevronDown className="h-4 w-4" />
{expandedCategoryMappings.roundingUnit ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
@ -2128,10 +2535,10 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs font-normal"
disabled={targetTableColumns.length === 0}
disabled={!config.targetTable || loadedTargetTableColumns.length === 0}
>
{mapping.targetField
? targetTableColumns.find((c) => c.columnName === mapping.targetField)?.columnLabel ||
? loadedTargetTableColumns.find((c) => c.columnName === mapping.targetField)?.columnLabel ||
mapping.targetField
: "저장 테이블 컬럼 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
@ -2141,13 +2548,15 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList>
{targetTableColumns.length === 0 ? (
<CommandEmpty className="text-xs"> </CommandEmpty>
{!config.targetTable ? (
<CommandEmpty className="text-xs"> </CommandEmpty>
) : loadedTargetTableColumns.length === 0 ? (
<CommandEmpty className="text-xs"> ...</CommandEmpty>
) : (
<>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{targetTableColumns.map((col) => {
{loadedTargetTableColumns.map((col) => {
const searchValue = `${col.columnLabel || col.columnName} ${col.columnName} ${col.dataType || ""}`.toLowerCase();
return (
<CommandItem
@ -2169,7 +2578,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<div className="flex flex-col">
<span>{col.columnLabel || col.columnName}</span>
{col.dataType && (
<span className="text-[10px] text-muted-foreground">{col.dataType}</span>
<span className="text-[10px] text-muted-foreground">
{col.dataType}
</span>
)}
</div>
</CommandItem>
@ -2182,17 +2593,27 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
</Command>
</PopoverContent>
</Popover>
<p className="text-[8px] text-muted-foreground">
({config.targetTable || "미선택"})
</p>
</div>
{/* 기본값 (선택사항) */}
<div className="space-y-1">
<Label className="text-[9px] sm:text-[10px]"> ()</Label>
<Input
value={mapping.defaultValue || ""}
value={localMappingInputs[index] !== undefined ? localMappingInputs[index] : mapping.defaultValue || ""}
onChange={(e) => {
const updated = [...(config.parentDataMapping || [])];
updated[index] = { ...updated[index], defaultValue: e.target.value };
handleChange("parentDataMapping", updated);
const newValue = e.target.value;
setLocalMappingInputs(prev => ({ ...prev, [index]: newValue }));
}}
onBlur={() => {
const currentValue = localMappingInputs[index];
if (currentValue !== undefined) {
const updated = [...(config.parentDataMapping || [])];
updated[index] = { ...updated[index], defaultValue: currentValue || undefined };
handleChange("parentDataMapping", updated);
}
}}
placeholder="값이 없을 때 사용할 기본값"
className="h-7 text-xs"
@ -2200,46 +2621,24 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
</div>
{/* 삭제 버튼 */}
<Button
size="sm"
variant="ghost"
className="h-6 w-full text-xs text-destructive hover:text-destructive"
onClick={() => {
const updated = (config.parentDataMapping || []).filter((_, i) => i !== index);
handleChange("parentDataMapping", updated);
}}
>
<X className="mr-1 h-3 w-3" />
</Button>
<div className="flex justify-end pt-2">
<Button
size="sm"
variant="ghost"
className="h-7 text-xs text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => {
const updated = (config.parentDataMapping || []).filter((_, i) => i !== index);
handleChange("parentDataMapping", updated);
}}
>
<X className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
</Card>
))}
</div>
{(config.parentDataMapping || []).length === 0 && (
<p className="text-center text-[10px] text-muted-foreground py-4">
. "추가" .
</p>
)}
{/* 예시 */}
<div className="rounded-lg bg-green-50 p-2 text-xs">
<p className="mb-1 text-[10px] font-medium text-green-900">💡 </p>
<div className="space-y-1 text-[9px] text-green-700">
<p><strong> 1: 거래처 ID</strong></p>
<p className="ml-2"> : <code className="bg-green-100 px-1">customer_mng</code></p>
<p className="ml-2"> : <code className="bg-green-100 px-1">id</code> : <code className="bg-green-100 px-1">customer_id</code></p>
<p className="mt-1"><strong> 2: 품목 ID</strong></p>
<p className="ml-2"> : <code className="bg-green-100 px-1">item_info</code></p>
<p className="ml-2"> : <code className="bg-green-100 px-1">id</code> : <code className="bg-green-100 px-1">item_id</code></p>
<p className="mt-1"><strong> 3: 품목 </strong></p>
<p className="ml-2"> : <code className="bg-green-100 px-1">item_info</code></p>
<p className="ml-2"> : <code className="bg-green-100 px-1">standard_price</code> : <code className="bg-green-100 px-1">base_price</code></p>
</div>
</div>
</div>
{/* 사용 예시 */}
@ -2256,3 +2655,5 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
};
SelectedItemsDetailInputConfigPanel.displayName = "SelectedItemsDetailInputConfigPanel";
export default SelectedItemsDetailInputConfigPanel;

View File

@ -23,6 +23,7 @@ interface SplitPanelLayoutConfigPanelProps {
onChange: (config: SplitPanelLayoutConfig) => void;
tables?: TableInfo[]; // 전체 테이블 목록 (선택적)
screenTableName?: string; // 현재 화면의 테이블명 (좌측 패널에서 사용)
menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 값 조회 시 필요)
}
/**
@ -201,6 +202,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
onChange,
tables = [], // 기본값 빈 배열 (현재 화면 테이블만)
screenTableName, // 현재 화면의 테이블명
menuObjid, // 🆕 메뉴 OBJID
}) => {
const [rightTableOpen, setRightTableOpen] = useState(false);
const [leftColumnOpen, setLeftColumnOpen] = useState(false);
@ -211,9 +213,26 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
// 엔티티 참조 테이블 컬럼
type EntityRefTable = { tableName: string; columns: ColumnInfo[] };
const [entityReferenceTables, setEntityReferenceTables] = useState<Record<string, EntityRefTable[]>>({});
// 🆕 입력 필드용 로컬 상태
const [isUserEditing, setIsUserEditing] = useState(false);
const [localTitles, setLocalTitles] = useState({
left: config.leftPanel?.title || "",
right: config.rightPanel?.title || "",
});
// 관계 타입
const relationshipType = config.rightPanel?.relation?.type || "detail";
// config 변경 시 로컬 타이틀 동기화 (사용자가 입력 중이 아닐 때만)
useEffect(() => {
if (!isUserEditing) {
setLocalTitles({
left: config.leftPanel?.title || "",
right: config.rightPanel?.title || "",
});
}
}, [config.leftPanel?.title, config.rightPanel?.title, isUserEditing]);
// 조인 모드일 때만 전체 테이블 목록 로드
useEffect(() => {
@ -568,8 +587,15 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
<div className="space-y-2">
<Label> </Label>
<Input
value={config.leftPanel?.title || ""}
onChange={(e) => updateLeftPanel({ title: e.target.value })}
value={localTitles.left}
onChange={(e) => {
setIsUserEditing(true);
setLocalTitles(prev => ({ ...prev, left: e.target.value }));
}}
onBlur={() => {
setIsUserEditing(false);
updateLeftPanel({ title: localTitles.left });
}}
placeholder="좌측 패널 제목"
/>
</div>
@ -1345,6 +1371,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
} as any))}
config={config.leftPanel?.dataFilter}
onConfigChange={(dataFilter) => updateLeftPanel({ dataFilter })}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
/>
</div>
@ -1355,8 +1382,15 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
<div className="space-y-2">
<Label> </Label>
<Input
value={config.rightPanel?.title || ""}
onChange={(e) => updateRightPanel({ title: e.target.value })}
value={localTitles.right}
onChange={(e) => {
setIsUserEditing(true);
setLocalTitles(prev => ({ ...prev, right: e.target.value }));
}}
onBlur={() => {
setIsUserEditing(false);
updateRightPanel({ title: localTitles.right });
}}
placeholder="우측 패널 제목"
/>
</div>
@ -2270,6 +2304,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
} as any))}
config={config.rightPanel?.dataFilter}
onConfigChange={(dataFilter) => updateRightPanel({ dataFilter })}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
/>
</div>

View File

@ -2404,18 +2404,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</div>
</td>
</tr>
) : (() => {
console.log("🔍 [TableList] 렌더링 조건 체크", {
groupByColumns: groupByColumns.length,
groupedDataLength: groupedData.length,
willRenderGrouped: groupByColumns.length > 0 && groupedData.length > 0,
dataLength: data.length,
});
return groupByColumns.length > 0 && groupedData.length > 0;
})() ? (
) : groupByColumns.length > 0 && groupedData.length > 0 ? (
// 그룹화된 렌더링
groupedData.map((group) => {
console.log("📊 [TableList] 그룹 렌더링:", group.groupKey, group.count);
const isCollapsed = collapsedGroups.has(group.groupKey);
return (
<React.Fragment key={group.groupKey}>
@ -2508,10 +2499,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
})
) : (
// 일반 렌더링 (그룹 없음)
(() => {
console.log("📋 [TableList] 일반 렌더링 시작:", data.length, "개 행");
return data;
})().map((row, index) => (
data.map((row, index) => (
<tr
key={index}
className={cn(

View File

@ -255,7 +255,8 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
}, [config.columns]);
const handleChange = (key: keyof TableListConfig, value: any) => {
onChange({ [key]: value });
// 기존 config와 병합하여 전달 (다른 속성 손실 방지)
onChange({ ...config, [key]: value });
};
const handleNestedChange = (parentKey: keyof TableListConfig, childKey: string, value: any) => {

View File

@ -11,6 +11,8 @@ import { FilterPanel } from "@/components/screen/table-options/FilterPanel";
import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
import { TableFilter } from "@/types/table-options";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ModernDatePicker } from "@/components/screen/filters/ModernDatePicker";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
interface PresetFilter {
id: string;
@ -43,6 +45,7 @@ interface TableSearchWidgetProps {
export function TableSearchWidget({ component, screenId, onHeightChange }: TableSearchWidgetProps) {
const { registeredTables, selectedTableId, setSelectedTableId, getTable } = useTableOptions();
const { isPreviewMode } = useScreenPreview(); // 미리보기 모드 확인
// 높이 관리 context (실제 화면에서만 사용)
let setWidgetHeight:
@ -62,7 +65,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// 활성화된 필터 목록
const [activeFilters, setActiveFilters] = useState<TableFilter[]>([]);
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
const [filterValues, setFilterValues] = useState<Record<string, any>>({});
// select 타입 필터의 옵션들
const [selectOptions, setSelectOptions] = useState<Record<string, Array<{ label: string; value: string }>>>({});
// 선택된 값의 라벨 저장 (데이터 없을 때도 라벨 유지)
@ -230,7 +233,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
const hasMultipleTables = tableList.length > 1;
// 필터 값 변경 핸들러
const handleFilterChange = (columnName: string, value: string) => {
const handleFilterChange = (columnName: string, value: any) => {
const newValues = {
...filterValues,
[columnName]: value,
@ -243,14 +246,51 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
};
// 필터 적용 함수
const applyFilters = (values: Record<string, string> = filterValues) => {
const applyFilters = (values: Record<string, any> = filterValues) => {
// 빈 값이 아닌 필터만 적용
const filtersWithValues = activeFilters
.map((filter) => ({
...filter,
value: values[filter.columnName] || "",
}))
.filter((f) => f.value !== "");
.map((filter) => {
let filterValue = values[filter.columnName];
// 날짜 범위 객체를 처리
if (filter.filterType === "date" && filterValue && typeof filterValue === "object" && (filterValue.from || filterValue.to)) {
// 날짜 범위 객체를 문자열 형식으로 변환 (백엔드 재시작 불필요)
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
// "YYYY-MM-DD|YYYY-MM-DD" 형식으로 변환
const fromStr = filterValue.from ? formatDate(filterValue.from) : "";
const toStr = filterValue.to ? formatDate(filterValue.to) : "";
if (fromStr && toStr) {
// 둘 다 있으면 파이프로 연결
filterValue = `${fromStr}|${toStr}`;
} else if (fromStr) {
// 시작일만 있으면
filterValue = `${fromStr}|`;
} else if (toStr) {
// 종료일만 있으면
filterValue = `|${toStr}`;
} else {
filterValue = "";
}
}
return {
...filter,
value: filterValue || "",
};
})
.filter((f) => {
// 빈 값 체크
if (!f.value) return false;
if (typeof f.value === "string" && f.value === "") return false;
return true;
});
currentTable?.onFilterChange(filtersWithValues);
};
@ -271,14 +311,21 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
switch (filter.filterType) {
case "date":
return (
<Input
type="date"
value={value}
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
className="h-9 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
placeholder={column?.columnLabel}
/>
<div style={{ width: `${width}px` }}>
<ModernDatePicker
label={column?.columnLabel || filter.columnName}
value={value ? (typeof value === 'string' ? { from: new Date(value), to: new Date(value) } : value) : {}}
onChange={(dateRange) => {
if (dateRange.from && dateRange.to) {
// 기간이 선택되면 from과 to를 모두 저장
handleFilterChange(filter.columnName, dateRange);
} else {
handleFilterChange(filter.columnName, "");
}
}}
includeTime={false}
/>
</div>
);
case "number":
@ -400,14 +447,14 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
</div>
)}
{/* 동적 모드일 때만 설정 버튼들 표시 */}
{/* 동적 모드일 때만 설정 버튼들 표시 (미리보기에서는 비활성화) */}
{filterMode === "dynamic" && (
<>
<Button
variant="outline"
size="sm"
onClick={() => setColumnVisibilityOpen(true)}
disabled={!selectedTableId}
onClick={() => !isPreviewMode && setColumnVisibilityOpen(true)}
disabled={!selectedTableId || isPreviewMode}
className="h-8 text-xs sm:h-9 sm:text-sm"
>
<Settings className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
@ -417,8 +464,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
<Button
variant="outline"
size="sm"
onClick={() => setFilterOpen(true)}
disabled={!selectedTableId}
onClick={() => !isPreviewMode && setFilterOpen(true)}
disabled={!selectedTableId || isPreviewMode}
className="h-8 text-xs sm:h-9 sm:text-sm"
>
<Filter className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
@ -428,8 +475,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
<Button
variant="outline"
size="sm"
onClick={() => setGroupingOpen(true)}
disabled={!selectedTableId}
onClick={() => !isPreviewMode && setGroupingOpen(true)}
disabled={!selectedTableId || isPreviewMode}
className="h-8 text-xs sm:h-9 sm:text-sm"
>
<Layers className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />

View File

@ -83,6 +83,10 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
// autoGeneratedValue,
// });
// 자동생성 원본 값 추적 (수동/자동 모드 구분용)
const [originalAutoGeneratedValue, setOriginalAutoGeneratedValue] = useState<string>("");
const [isManualMode, setIsManualMode] = useState<boolean>(false);
// 자동생성 값 생성 (컴포넌트 마운트 시 한 번만 실행)
useEffect(() => {
const generateAutoValue = async () => {
@ -136,6 +140,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
if (generatedValue) {
console.log("✅ 자동생성 값 설정:", generatedValue);
setAutoGeneratedValue(generatedValue);
setOriginalAutoGeneratedValue(generatedValue); // 🆕 원본 값 저장
hasGeneratedRef.current = true; // 생성 완료 플래그
// 폼 데이터에 자동생성된 값 설정 (인터랙티브 모드에서만)
@ -684,6 +689,20 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
</label>
)}
{/* 수동/자동 모드 표시 배지 */}
{testAutoGeneration.enabled && testAutoGeneration.type === "numbering_rule" && isInteractive && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<span className={cn(
"text-[10px] px-2 py-0.5 rounded-full font-medium",
isManualMode
? "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
)}>
{isManualMode ? "수동" : "자동"}
</span>
</div>
)}
<input
type={inputType}
defaultValue={(() => {
@ -704,20 +723,24 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
})()}
placeholder={
testAutoGeneration.enabled && testAutoGeneration.type !== "none"
? `자동생성: ${AutoGenerationUtils.getTypeDescription(testAutoGeneration.type)}`
? isManualMode
? "수동 입력 모드"
: `자동생성: ${AutoGenerationUtils.getTypeDescription(testAutoGeneration.type)}`
: componentConfig.placeholder || defaultPlaceholder
}
pattern={validationPattern}
title={
webType === "tel"
? "전화번호 형식: 010-1234-5678"
: isManualMode
? `${component.label} (수동 입력 모드 - 채번 규칙 미적용)`
: component.label
? `${component.label}${component.columnName ? ` (${component.columnName})` : ""}`
: component.columnName || undefined
}
disabled={componentConfig.disabled || false}
required={componentConfig.required || false}
readOnly={componentConfig.readonly || (testAutoGeneration.enabled && testAutoGeneration.type !== "none")}
readOnly={componentConfig.readonly || false}
className={cn(
"box-border h-full w-full max-w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
@ -742,6 +765,44 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
// hasOnChange: !!props.onChange,
// });
// 🆕 사용자 수정 감지 (자동 생성 값과 다르면 수동 모드로 전환)
if (testAutoGeneration.enabled && testAutoGeneration.type === "numbering_rule") {
if (originalAutoGeneratedValue && newValue !== originalAutoGeneratedValue) {
if (!isManualMode) {
setIsManualMode(true);
console.log("🔄 수동 모드로 전환:", {
field: component.columnName,
original: originalAutoGeneratedValue,
modified: newValue
});
// 🆕 채번 규칙 ID 제거 (수동 모드이므로 더 이상 채번 규칙 사용 안 함)
if (isInteractive && onFormDataChange && component.columnName) {
const ruleIdKey = `${component.columnName}_numberingRuleId`;
onFormDataChange(ruleIdKey, null);
console.log("🗑️ 채번 규칙 ID 제거 (수동 모드):", ruleIdKey);
}
}
} else if (isManualMode && newValue === originalAutoGeneratedValue) {
// 사용자가 원본 값으로 되돌렸을 때 자동 모드로 복구
setIsManualMode(false);
console.log("🔄 자동 모드로 복구:", {
field: component.columnName,
value: newValue
});
// 채번 규칙 ID 복구
if (isInteractive && onFormDataChange && component.columnName) {
const ruleId = testAutoGeneration.options?.numberingRuleId;
if (ruleId) {
const ruleIdKey = `${component.columnName}_numberingRuleId`;
onFormDataChange(ruleIdKey, ruleId);
console.log("✅ 채번 규칙 ID 복구 (자동 모드):", ruleIdKey);
}
}
}
}
// isInteractive 모드에서는 formData 업데이트
if (isInteractive && onFormDataChange && component.columnName) {
onFormDataChange(component.columnName, newValue);

View File

@ -392,27 +392,12 @@ export class ButtonActionExecutor {
// console.log("📋 채번 규칙이 설정된 필드:", fieldsWithNumbering);
// console.log("📊 필드 개수:", Object.keys(fieldsWithNumbering).length);
// 각 필드에 대해 실제 코드 할당
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
try {
// console.log(`🎫 ${fieldName} 필드에 채번 규칙 ${ruleId} 할당 시작`);
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
const response = await allocateNumberingCode(ruleId);
// console.log(`📡 API 응답 (${fieldName}):`, response);
if (response.success && response.data) {
const generatedCode = response.data.generatedCode;
formData[fieldName] = generatedCode;
// console.log(`✅ ${fieldName} = ${generatedCode} (할당 완료)`);
} else {
console.error(`❌ 채번 규칙 할당 실패 (${fieldName}):`, response.error);
toast.error(`${fieldName} 채번 규칙 할당 실패: ${response.error}`);
}
} catch (error) {
console.error(`❌ 채번 규칙 할당 오류 (${fieldName}):`, error);
toast.error(`${fieldName} 채번 규칙 할당 오류`);
}
// 사용자 입력 값 유지 (재할당하지 않음)
// 채번 규칙은 TextInputComponent 마운트 시 이미 생성되었으므로
// 저장 시점에는 사용자가 수정한 값을 그대로 사용
if (Object.keys(fieldsWithNumbering).length > 0) {
console.log(" 채번 규칙 필드 감지:", Object.keys(fieldsWithNumbering));
console.log(" 사용자 입력 값 유지 (재할당 하지 않음)");
}
// console.log("✅ 채번 규칙 할당 완료");

View File

@ -378,3 +378,4 @@ interface TablePermission {