Merge remote-tracking branch 'origin/main' into ksh
This commit is contained in:
commit
b3e1e620da
|
|
@ -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] 채번 규칙 생성 성공:", {
|
||||
|
|
|
|||
|
|
@ -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, // 🆕 테이블명 추가
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ export interface LayoutData {
|
|||
components: ComponentData[];
|
||||
gridSettings?: GridSettings;
|
||||
screenResolution?: ScreenResolution;
|
||||
tableName?: string; // 🆕 화면에 연결된 테이블명
|
||||
}
|
||||
|
||||
// 그리드 설정
|
||||
|
|
|
|||
|
|
@ -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 설정 (필터링용)
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} // ✅ 회사 코드 전달
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
닫기
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -78,3 +78,4 @@ export const numberingRuleTemplate = {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -430,7 +430,7 @@ const ResizableDialogContent = React.forwardRef<
|
|||
<div
|
||||
ref={contentRef}
|
||||
className="h-full w-full relative"
|
||||
style={{ display: 'block', overflow: 'hidden', pointerEvents: 'auto', zIndex: 1 }}
|
||||
style={{ display: 'block', overflow: 'auto', pointerEvents: 'auto', zIndex: 1 }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 목록 조회 (메뉴 스코프)
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -378,3 +378,4 @@ interface TablePermission {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue