Merge remote-tracking branch 'upstream/main'
This commit is contained in:
@@ -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} // ✅ 회사 코드 전달
|
||||
|
||||
@@ -401,22 +401,10 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||
|
||||
// 컴포넌트 스타일 계산
|
||||
const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
|
||||
const isSectionPaper = type === "component" && (component as any).componentConfig?.type === "section-paper";
|
||||
|
||||
// 높이 결정 로직
|
||||
let finalHeight = size?.height || 10;
|
||||
if (isFlowWidget && actualHeight) {
|
||||
finalHeight = actualHeight;
|
||||
}
|
||||
|
||||
// 🔍 디버깅: position.x 값 확인
|
||||
const positionX = position?.x || 0;
|
||||
console.log("🔍 RealtimePreview componentStyle 설정:", {
|
||||
componentId: id,
|
||||
positionX,
|
||||
sizeWidth: size?.width,
|
||||
styleWidth: style?.width,
|
||||
willUse100Percent: positionX === 0,
|
||||
});
|
||||
const positionY = position?.y || 0;
|
||||
|
||||
// 너비 결정 로직: style.width (퍼센트) > 조건부 100% > size.width (픽셀)
|
||||
const getWidth = () => {
|
||||
@@ -432,20 +420,35 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||
return size?.width || 200;
|
||||
};
|
||||
|
||||
// 높이 결정 로직: style.height > actualHeight (Flow Widget) > size.height
|
||||
const getHeight = () => {
|
||||
// 1순위: style.height가 있으면 우선 사용 (픽셀/퍼센트 값)
|
||||
if (style?.height) {
|
||||
return style.height;
|
||||
}
|
||||
// 2순위: Flow Widget의 실제 측정 높이
|
||||
if (isFlowWidget && actualHeight) {
|
||||
return actualHeight;
|
||||
}
|
||||
// 3순위: size.height 픽셀 값
|
||||
return size?.height || 10;
|
||||
};
|
||||
|
||||
const componentStyle = {
|
||||
position: "absolute" as const,
|
||||
...style, // 먼저 적용하고
|
||||
left: positionX,
|
||||
top: position?.y || 0,
|
||||
top: positionY,
|
||||
width: getWidth(), // 우선순위에 따른 너비
|
||||
height: finalHeight,
|
||||
height: getHeight(), // 우선순위에 따른 높이
|
||||
zIndex: position?.z || 1,
|
||||
// right 속성 강제 제거
|
||||
right: undefined,
|
||||
};
|
||||
|
||||
// 선택된 컴포넌트 스타일
|
||||
const selectionStyle = isSelected
|
||||
// Section Paper는 자체적으로 선택 상태 테두리를 처리하므로 outline 제거
|
||||
const selectionStyle = isSelected && !isSectionPaper
|
||||
? {
|
||||
outline: "2px solid rgb(59, 130, 246)",
|
||||
outlineOffset: "2px",
|
||||
@@ -628,6 +631,24 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */}
|
||||
{type === "component" && (() => {
|
||||
const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer");
|
||||
return (
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
isSelected={isSelected}
|
||||
isDesignMode={isDesignMode}
|
||||
onClick={onClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
{...restProps}
|
||||
>
|
||||
{children}
|
||||
</DynamicComponentRenderer>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
|
||||
{type === "widget" && !isFileComponent(component) && (
|
||||
<div className="h-full w-full">
|
||||
|
||||
@@ -4603,10 +4603,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
});
|
||||
}}
|
||||
>
|
||||
{/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
|
||||
{/* 컨테이너, 그룹, 영역, 컴포넌트의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
|
||||
{(component.type === "group" ||
|
||||
component.type === "container" ||
|
||||
component.type === "area") &&
|
||||
component.type === "area" ||
|
||||
component.type === "component") &&
|
||||
layout.components
|
||||
.filter((child) => child.parentId === component.id)
|
||||
.map((child) => {
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -452,7 +452,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>
|
||||
|
||||
@@ -83,11 +83,22 @@ export function SectionPaperComponent({
|
||||
? { backgroundColor: config.customColor }
|
||||
: {};
|
||||
|
||||
// 선택 상태 테두리 처리 (outline 사용하여 크기 영향 없음)
|
||||
const selectionStyle = isDesignMode && isSelected
|
||||
? {
|
||||
outline: "2px solid #3b82f6",
|
||||
outlineOffset: "0px", // 크기에 영향 없이 딱 맞게 표시
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// 기본 스타일
|
||||
"relative transition-colors overflow-visible",
|
||||
"relative transition-colors",
|
||||
|
||||
// 높이 고정을 위한 overflow 처리
|
||||
"overflow-auto",
|
||||
|
||||
// 배경색
|
||||
backgroundColor !== "custom" && backgroundColorMap[backgroundColor],
|
||||
@@ -101,37 +112,36 @@ export function SectionPaperComponent({
|
||||
// 그림자
|
||||
shadowMap[shadow],
|
||||
|
||||
// 테두리 (선택)
|
||||
showBorder &&
|
||||
// 테두리 (선택 상태가 아닐 때만)
|
||||
!isSelected && showBorder &&
|
||||
borderStyle === "subtle" &&
|
||||
"border border-border/30",
|
||||
|
||||
// 디자인 모드에서 선택된 상태
|
||||
isDesignMode && isSelected && "ring-2 ring-primary ring-offset-2",
|
||||
|
||||
// 디자인 모드에서 빈 상태 표시
|
||||
isDesignMode && !children && "min-h-[100px] border-2 border-dashed border-muted-foreground/30",
|
||||
// 디자인 모드에서 빈 상태 표시 (테두리만, 최소 높이 제거)
|
||||
isDesignMode && !children && "border-2 border-dashed border-muted-foreground/30",
|
||||
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
// 크기를 100%로 설정하여 부모 크기에 맞춤
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
boxSizing: "border-box", // padding과 border를 크기에 포함
|
||||
...customBgStyle,
|
||||
...component?.style,
|
||||
...selectionStyle,
|
||||
...component?.style, // 사용자 설정이 최종 우선순위
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* 디자인 모드에서 빈 상태 안내 */}
|
||||
{isDesignMode && !children && (
|
||||
{/* 자식 컴포넌트들 */}
|
||||
{children || (isDesignMode && (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
<div className="text-center">
|
||||
<div className="mb-1">📄 Section Paper</div>
|
||||
<div className="text-xs">컴포넌트를 이곳에 배치하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 자식 컴포넌트들 */}
|
||||
{children}
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ 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 [localDisplayItemInputs, setLocalDisplayItemInputs] = useState<Record<string, Record<number, { label?: string; value?: string }>>>({});
|
||||
|
||||
|
||||
// 🆕 그룹별 펼침/접힘 상태
|
||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
||||
@@ -140,6 +146,27 @@ 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;
|
||||
});
|
||||
}, [config.fieldGroups]);
|
||||
|
||||
// 🆕 초기 렌더링 시 기존 필드들의 autoFillFromTable 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (!localFields || localFields.length === 0) return;
|
||||
@@ -343,6 +370,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
|
||||
@@ -353,6 +387,13 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
||||
};
|
||||
|
||||
const updateFieldGroup = (groupId: string, updates: Partial<FieldGroup>) => {
|
||||
// 1. 로컬 입력 상태 즉시 업데이트 (포커스 유지)
|
||||
setLocalGroupInputs(prev => ({
|
||||
...prev,
|
||||
[groupId]: { ...prev[groupId], ...updates }
|
||||
}));
|
||||
|
||||
// 2. 실제 그룹 데이터 업데이트
|
||||
const newGroups = localFieldGroups.map(g =>
|
||||
g.id === groupId ? { ...g, ...updates } : g
|
||||
);
|
||||
@@ -1036,8 +1077,15 @@ 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) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalGroupInputs(prev => ({
|
||||
...prev,
|
||||
[group.id]: { ...prev[group.id], id: newValue }
|
||||
}));
|
||||
updateFieldGroup(group.id, { id: newValue });
|
||||
}}
|
||||
className="h-7 text-xs sm:h-8 sm:text-sm"
|
||||
placeholder="group_customer"
|
||||
/>
|
||||
@@ -1047,8 +1095,15 @@ 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) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalGroupInputs(prev => ({
|
||||
...prev,
|
||||
[group.id]: { ...prev[group.id], title: newValue }
|
||||
}));
|
||||
updateFieldGroup(group.id, { title: newValue });
|
||||
}}
|
||||
className="h-7 text-xs sm:h-8 sm:text-sm"
|
||||
placeholder="거래처 정보"
|
||||
/>
|
||||
@@ -1058,8 +1113,15 @@ 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) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalGroupInputs(prev => ({
|
||||
...prev,
|
||||
[group.id]: { ...prev[group.id], description: newValue }
|
||||
}));
|
||||
updateFieldGroup(group.id, { description: newValue });
|
||||
}}
|
||||
className="h-7 text-xs sm:h-8 sm:text-sm"
|
||||
placeholder="거래처 관련 정보를 입력합니다"
|
||||
/>
|
||||
@@ -1070,8 +1132,15 @@ 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) => {
|
||||
const newValue = parseInt(e.target.value) || 0;
|
||||
setLocalGroupInputs(prev => ({
|
||||
...prev,
|
||||
[group.id]: { ...prev[group.id], order: newValue }
|
||||
}));
|
||||
updateFieldGroup(group.id, { order: newValue });
|
||||
}}
|
||||
className="h-7 text-xs sm:h-8 sm:text-sm"
|
||||
min="0"
|
||||
/>
|
||||
@@ -1177,8 +1246,27 @@ 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
|
||||
}
|
||||
}
|
||||
}));
|
||||
// 실제 상태 업데이트
|
||||
updateDisplayItemInGroup(group.id, itemIndex, { value: newValue });
|
||||
}}
|
||||
placeholder="| , / , -"
|
||||
className="h-6 text-[9px] sm:text-[10px]"
|
||||
/>
|
||||
@@ -1206,8 +1294,27 @@ 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
|
||||
}
|
||||
}
|
||||
}));
|
||||
// 실제 상태 업데이트
|
||||
updateDisplayItemInGroup(group.id, itemIndex, { label: newValue });
|
||||
}}
|
||||
placeholder="라벨 (예: 거래처:)"
|
||||
className="h-6 w-full text-[9px] sm:text-[10px]"
|
||||
/>
|
||||
|
||||
@@ -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,7 @@ 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";
|
||||
|
||||
interface PresetFilter {
|
||||
id: string;
|
||||
@@ -62,7 +63,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 +231,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 +244,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 +309,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":
|
||||
|
||||
Reference in New Issue
Block a user