Merge remote-tracking branch 'upstream/main'

This commit is contained in:
dohyeons 2025-11-26 09:33:42 +09:00
commit e01ecd70dc
18 changed files with 536 additions and 105 deletions

View File

@ -132,6 +132,16 @@ router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Respo
return res.status(400).json({ success: false, error: "최소 1개 이상의 규칙 파트가 필요합니다" }); 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); const newRule = await numberingRuleService.createRule(ruleConfig, companyCode, userId);
logger.info("✅ [POST /numbering-rules] 채번 규칙 생성 성공:", { logger.info("✅ [POST /numbering-rules] 채번 규칙 생성 성공:", {

View File

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

View File

@ -1165,6 +1165,23 @@ export class TableManagementService {
paramCount: number; paramCount: number;
} | null> { } | null> {
try { 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} 형태의 필터 객체 처리 // 🔧 {value, operator} 형태의 필터 객체 처리
let actualValue = value; let actualValue = value;
let operator = "contains"; // 기본값 let operator = "contains"; // 기본값
@ -1193,6 +1210,12 @@ export class TableManagementService {
// 컬럼 타입 정보 조회 // 컬럼 타입 정보 조회
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); 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) { if (!columnInfo) {
// 컬럼 정보가 없으면 operator에 따른 기본 검색 // 컬럼 정보가 없으면 operator에 따른 기본 검색
@ -1292,20 +1315,41 @@ export class TableManagementService {
const values: any[] = []; const values: any[] = [];
let paramCount = 0; 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) { if (value.from) {
conditions.push(`${columnName} >= $${paramIndex + paramCount}`); // VARCHAR 컬럼을 DATE로 캐스팅하여 비교
conditions.push(`${columnName}::date >= $${paramIndex + paramCount}::date`);
values.push(value.from); values.push(value.from);
paramCount++; paramCount++;
} }
if (value.to) { if (value.to) {
conditions.push(`${columnName} <= $${paramIndex + paramCount}`); // 종료일은 해당 날짜의 23:59:59까지 포함
conditions.push(`${columnName}::date <= $${paramIndex + paramCount}::date`);
values.push(value.to); values.push(value.to);
paramCount++; 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); values.push(value);
paramCount = 1; paramCount = 1;
} }
@ -1544,6 +1588,7 @@ export class TableManagementService {
columnName: string columnName: string
): Promise<{ ): Promise<{
webType: string; webType: string;
inputType?: string;
codeCategory?: string; codeCategory?: string;
referenceTable?: string; referenceTable?: string;
referenceColumn?: string; referenceColumn?: string;
@ -1552,29 +1597,44 @@ export class TableManagementService {
try { try {
const result = await queryOne<{ const result = await queryOne<{
web_type: string | null; web_type: string | null;
input_type: string | null;
code_category: string | null; code_category: string | null;
reference_table: string | null; reference_table: string | null;
reference_column: string | null; reference_column: string | null;
display_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 FROM column_labels
WHERE table_name = $1 AND column_name = $2 WHERE table_name = $1 AND column_name = $2
LIMIT 1`, LIMIT 1`,
[tableName, columnName] [tableName, columnName]
); );
logger.info(`🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`, {
found: !!result,
web_type: result?.web_type,
input_type: result?.input_type,
});
if (!result) { if (!result) {
logger.warn(`⚠️ [getColumnWebTypeInfo] 컬럼 정보 없음: ${tableName}.${columnName}`);
return null; return null;
} }
return { // web_type이 없으면 input_type을 사용 (레거시 호환)
webType: result.web_type || "", const webType = result.web_type || result.input_type || "";
const columnInfo = {
webType: webType,
inputType: result.input_type || "",
codeCategory: result.code_category || undefined, codeCategory: result.code_category || undefined,
referenceTable: result.reference_table || undefined, referenceTable: result.reference_table || undefined,
referenceColumn: result.reference_column || undefined, referenceColumn: result.reference_column || undefined,
displayColumn: result.display_column || undefined, displayColumn: result.display_column || undefined,
}; };
logger.info(`✅ [getColumnWebTypeInfo] 반환값: webType=${columnInfo.webType}, inputType=${columnInfo.inputType}`);
return columnInfo;
} catch (error) { } catch (error) {
logger.error( logger.error(
`컬럼 웹타입 정보 조회 실패: ${tableName}.${columnName}`, `컬럼 웹타입 정보 조회 실패: ${tableName}.${columnName}`,

View File

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

View File

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

View File

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

View File

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

View File

@ -401,22 +401,10 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
// 컴포넌트 스타일 계산 // 컴포넌트 스타일 계산
const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget"); 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; const positionX = position?.x || 0;
console.log("🔍 RealtimePreview componentStyle 설정:", { const positionY = position?.y || 0;
componentId: id,
positionX,
sizeWidth: size?.width,
styleWidth: style?.width,
willUse100Percent: positionX === 0,
});
// 너비 결정 로직: style.width (퍼센트) > 조건부 100% > size.width (픽셀) // 너비 결정 로직: style.width (퍼센트) > 조건부 100% > size.width (픽셀)
const getWidth = () => { const getWidth = () => {
@ -432,20 +420,35 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
return size?.width || 200; 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 = { const componentStyle = {
position: "absolute" as const, position: "absolute" as const,
...style, // 먼저 적용하고 ...style, // 먼저 적용하고
left: positionX, left: positionX,
top: position?.y || 0, top: positionY,
width: getWidth(), // 우선순위에 따른 너비 width: getWidth(), // 우선순위에 따른 너비
height: finalHeight, height: getHeight(), // 우선순위에 따른 높이
zIndex: position?.z || 1, zIndex: position?.z || 1,
// right 속성 강제 제거 // right 속성 강제 제거
right: undefined, right: undefined,
}; };
// 선택된 컴포넌트 스타일 // 선택된 컴포넌트 스타일
const selectionStyle = isSelected // Section Paper는 자체적으로 선택 상태 테두리를 처리하므로 outline 제거
const selectionStyle = isSelected && !isSectionPaper
? { ? {
outline: "2px solid rgb(59, 130, 246)", outline: "2px solid rgb(59, 130, 246)",
outlineOffset: "2px", outlineOffset: "2px",
@ -628,6 +631,24 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
</div> </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) && ( {type === "widget" && !isFileComponent(component) && (
<div className="h-full w-full"> <div className="h-full w-full">

View File

@ -4603,10 +4603,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}); });
}} }}
> >
{/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */} {/* 컨테이너, 그룹, 영역, 컴포넌트의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
{(component.type === "group" || {(component.type === "group" ||
component.type === "container" || component.type === "container" ||
component.type === "area") && component.type === "area" ||
component.type === "component") &&
layout.components layout.components
.filter((child) => child.parentId === component.id) .filter((child) => child.parentId === component.id)
.map((child) => { .map((child) => {

View File

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

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react"; import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react";
@ -35,6 +35,17 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
const [currentMonth, setCurrentMonth] = useState(new Date()); const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectingType, setSelectingType] = useState<"from" | "to">("from"); 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) => { const formatDate = (date: Date | undefined) => {
if (!date) return ""; if (!date) return "";
if (includeTime) { if (includeTime) {
@ -57,26 +68,91 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
}; };
const handleDateClick = (date: Date) => { const handleDateClick = (date: Date) => {
// 로컬 상태만 업데이트 (onChange 호출 안 함)
if (selectingType === "from") { if (selectingType === "from") {
const newValue = { ...value, from: date }; setTempValue({ ...tempValue, from: date });
onChange(newValue);
setSelectingType("to"); setSelectingType("to");
} else { } else {
const newValue = { ...value, to: date }; setTempValue({ ...tempValue, to: date });
onChange(newValue);
setSelectingType("from"); setSelectingType("from");
} }
}; };
const handleClear = () => { const handleClear = () => {
onChange({}); setTempValue({});
setSelectingType("from"); setSelectingType("from");
}; };
const handleConfirm = () => { 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); setIsOpen(false);
setSelectingType("from"); setSelectingType("from");
// 날짜는 이미 선택 시점에 onChange가 호출되므로 중복 호출 제거
}; };
const monthStart = startOfMonth(currentMonth); const monthStart = startOfMonth(currentMonth);
@ -91,16 +167,16 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
const allDays = [...Array(paddingDays).fill(null), ...days]; const allDays = [...Array(paddingDays).fill(null), ...days];
const isInRange = (date: Date) => { const isInRange = (date: Date) => {
if (!value.from || !value.to) return false; if (!tempValue.from || !tempValue.to) return false;
return date >= value.from && date <= value.to; return date >= tempValue.from && date <= tempValue.to;
}; };
const isRangeStart = (date: Date) => { const isRangeStart = (date: Date) => {
return value.from && isSameDay(date, value.from); return tempValue.from && isSameDay(date, tempValue.from);
}; };
const isRangeEnd = (date: Date) => { const isRangeEnd = (date: Date) => {
return value.to && isSameDay(date, value.to); return tempValue.to && isSameDay(date, tempValue.to);
}; };
return ( return (
@ -127,6 +203,25 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
</div> </div>
</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"> <div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}> <Button variant="ghost" size="sm" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
@ -183,13 +278,13 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
</div> </div>
{/* 선택된 범위 표시 */} {/* 선택된 범위 표시 */}
{(value.from || value.to) && ( {(tempValue.from || tempValue.to) && (
<div className="bg-muted mb-4 rounded-md p-2"> <div className="bg-muted mb-4 rounded-md p-2">
<div className="text-muted-foreground mb-1 text-xs"> </div> <div className="text-muted-foreground mb-1 text-xs"> </div>
<div className="text-sm"> <div className="text-sm">
{value.from && <span className="font-medium">: {formatDate(value.from)}</span>} {tempValue.from && <span className="font-medium">: {formatDate(tempValue.from)}</span>}
{value.from && value.to && <span className="mx-2">~</span>} {tempValue.from && tempValue.to && <span className="mx-2">~</span>}
{value.to && <span className="font-medium">: {formatDate(value.to)}</span>} {tempValue.to && <span className="font-medium">: {formatDate(tempValue.to)}</span>}
</div> </div>
</div> </div>
)} )}
@ -200,7 +295,7 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
</Button> </Button>
<div className="space-x-2"> <div className="space-x-2">
<Button variant="outline" size="sm" onClick={() => setIsOpen(false)}> <Button variant="outline" size="sm" onClick={handleCancel}>
</Button> </Button>
<Button size="sm" onClick={handleConfirm}> <Button size="sm" onClick={handleConfirm}>

View File

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

View File

@ -452,7 +452,7 @@ const ResizableDialogContent = React.forwardRef<
<div <div
ref={contentRef} ref={contentRef}
className="h-full w-full relative" 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} {children}
</div> </div>

View File

@ -83,11 +83,22 @@ export function SectionPaperComponent({
? { backgroundColor: config.customColor } ? { backgroundColor: config.customColor }
: {}; : {};
// 선택 상태 테두리 처리 (outline 사용하여 크기 영향 없음)
const selectionStyle = isDesignMode && isSelected
? {
outline: "2px solid #3b82f6",
outlineOffset: "0px", // 크기에 영향 없이 딱 맞게 표시
}
: {};
return ( return (
<div <div
className={cn( className={cn(
// 기본 스타일 // 기본 스타일
"relative transition-colors overflow-visible", "relative transition-colors",
// 높이 고정을 위한 overflow 처리
"overflow-auto",
// 배경색 // 배경색
backgroundColor !== "custom" && backgroundColorMap[backgroundColor], backgroundColor !== "custom" && backgroundColorMap[backgroundColor],
@ -101,37 +112,36 @@ export function SectionPaperComponent({
// 그림자 // 그림자
shadowMap[shadow], shadowMap[shadow],
// 테두리 (선택) // 테두리 (선택 상태가 아닐 때만)
showBorder && !isSelected && showBorder &&
borderStyle === "subtle" && borderStyle === "subtle" &&
"border border-border/30", "border border-border/30",
// 디자인 모드에서 선택된 상태 // 디자인 모드에서 빈 상태 표시 (테두리만, 최소 높이 제거)
isDesignMode && isSelected && "ring-2 ring-primary ring-offset-2", isDesignMode && !children && "border-2 border-dashed border-muted-foreground/30",
// 디자인 모드에서 빈 상태 표시
isDesignMode && !children && "min-h-[100px] border-2 border-dashed border-muted-foreground/30",
className className
)} )}
style={{ style={{
// 크기를 100%로 설정하여 부모 크기에 맞춤
width: "100%",
height: "100%",
boxSizing: "border-box", // padding과 border를 크기에 포함
...customBgStyle, ...customBgStyle,
...component?.style, ...selectionStyle,
...component?.style, // 사용자 설정이 최종 우선순위
}} }}
onClick={onClick} onClick={onClick}
> >
{/* 디자인 모드에서 빈 상태 안내 */} {/* 자식 컴포넌트들 */}
{isDesignMode && !children && ( {children || (isDesignMode && (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm"> <div className="flex items-center justify-center h-full text-muted-foreground text-sm">
<div className="text-center"> <div className="text-center">
<div className="mb-1">📄 Section Paper</div> <div className="mb-1">📄 Section Paper</div>
<div className="text-xs"> </div> <div className="text-xs"> </div>
</div> </div>
</div> </div>
)} ))}
{/* 자식 컴포넌트들 */}
{children}
</div> </div>
); );
} }

View File

@ -50,6 +50,12 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
// 🆕 필드 그룹 상태 // 🆕 필드 그룹 상태
const [localFieldGroups, setLocalFieldGroups] = useState<FieldGroup[]>(config.fieldGroups || []); 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>>({}); const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
@ -140,6 +146,27 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
loadColumns(); loadColumns();
}, [config.targetTable]); }, [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 컬럼 로드 // 🆕 초기 렌더링 시 기존 필드들의 autoFillFromTable 컬럼 로드
useEffect(() => { useEffect(() => {
if (!localFields || localFields.length === 0) return; if (!localFields || localFields.length === 0) return;
@ -343,6 +370,13 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
}; };
const removeFieldGroup = (groupId: string) => { const removeFieldGroup = (groupId: string) => {
// 로컬 입력 상태에서 해당 그룹 제거
setLocalGroupInputs(prev => {
const newInputs = { ...prev };
delete newInputs[groupId];
return newInputs;
});
// 그룹 삭제 시 해당 그룹에 속한 필드들의 groupId도 제거 // 그룹 삭제 시 해당 그룹에 속한 필드들의 groupId도 제거
const updatedFields = localFields.map(field => const updatedFields = localFields.map(field =>
field.groupId === groupId ? { ...field, groupId: undefined } : 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>) => { const updateFieldGroup = (groupId: string, updates: Partial<FieldGroup>) => {
// 1. 로컬 입력 상태 즉시 업데이트 (포커스 유지)
setLocalGroupInputs(prev => ({
...prev,
[groupId]: { ...prev[groupId], ...updates }
}));
// 2. 실제 그룹 데이터 업데이트
const newGroups = localFieldGroups.map(g => const newGroups = localFieldGroups.map(g =>
g.id === groupId ? { ...g, ...updates } : g g.id === groupId ? { ...g, ...updates } : g
); );
@ -1036,8 +1077,15 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-[10px] sm:text-xs"> ID</Label> <Label className="text-[10px] sm:text-xs"> ID</Label>
<Input <Input
value={group.id} value={localGroupInputs[group.id]?.id !== undefined ? localGroupInputs[group.id].id : group.id}
onChange={(e) => updateFieldGroup(group.id, { id: e.target.value })} 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" className="h-7 text-xs sm:h-8 sm:text-sm"
placeholder="group_customer" placeholder="group_customer"
/> />
@ -1047,8 +1095,15 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-[10px] sm:text-xs"> </Label> <Label className="text-[10px] sm:text-xs"> </Label>
<Input <Input
value={group.title} value={localGroupInputs[group.id]?.title !== undefined ? localGroupInputs[group.id].title : group.title}
onChange={(e) => updateFieldGroup(group.id, { title: e.target.value })} 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" className="h-7 text-xs sm:h-8 sm:text-sm"
placeholder="거래처 정보" placeholder="거래처 정보"
/> />
@ -1058,8 +1113,15 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-[10px] sm:text-xs"> ()</Label> <Label className="text-[10px] sm:text-xs"> ()</Label>
<Input <Input
value={group.description || ""} value={localGroupInputs[group.id]?.description !== undefined ? localGroupInputs[group.id].description : (group.description || "")}
onChange={(e) => updateFieldGroup(group.id, { description: e.target.value })} 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" className="h-7 text-xs sm:h-8 sm:text-sm"
placeholder="거래처 관련 정보를 입력합니다" placeholder="거래처 관련 정보를 입력합니다"
/> />
@ -1070,8 +1132,15 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<Label className="text-[10px] sm:text-xs"> </Label> <Label className="text-[10px] sm:text-xs"> </Label>
<Input <Input
type="number" type="number"
value={group.order || 0} value={localGroupInputs[group.id]?.order !== undefined ? localGroupInputs[group.id].order : (group.order || 0)}
onChange={(e) => updateFieldGroup(group.id, { order: parseInt(e.target.value) || 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" className="h-7 text-xs sm:h-8 sm:text-sm"
min="0" min="0"
/> />
@ -1177,8 +1246,27 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
{/* 텍스트 설정 */} {/* 텍스트 설정 */}
{item.type === "text" && ( {item.type === "text" && (
<Input <Input
value={item.value || ""} value={
onChange={(e) => updateDisplayItemInGroup(group.id, itemIndex, { value: e.target.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="| , / , -" placeholder="| , / , -"
className="h-6 text-[9px] sm:text-[10px]" className="h-6 text-[9px] sm:text-[10px]"
/> />
@ -1206,8 +1294,27 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
{/* 라벨 */} {/* 라벨 */}
<Input <Input
value={item.label || ""} value={
onChange={(e) => updateDisplayItemInGroup(group.id, itemIndex, { label: e.target.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="라벨 (예: 거래처:)" placeholder="라벨 (예: 거래처:)"
className="h-6 w-full text-[9px] sm:text-[10px]" className="h-6 w-full text-[9px] sm:text-[10px]"
/> />

View File

@ -23,6 +23,7 @@ interface SplitPanelLayoutConfigPanelProps {
onChange: (config: SplitPanelLayoutConfig) => void; onChange: (config: SplitPanelLayoutConfig) => void;
tables?: TableInfo[]; // 전체 테이블 목록 (선택적) tables?: TableInfo[]; // 전체 테이블 목록 (선택적)
screenTableName?: string; // 현재 화면의 테이블명 (좌측 패널에서 사용) screenTableName?: string; // 현재 화면의 테이블명 (좌측 패널에서 사용)
menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 값 조회 시 필요)
} }
/** /**
@ -201,6 +202,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
onChange, onChange,
tables = [], // 기본값 빈 배열 (현재 화면 테이블만) tables = [], // 기본값 빈 배열 (현재 화면 테이블만)
screenTableName, // 현재 화면의 테이블명 screenTableName, // 현재 화면의 테이블명
menuObjid, // 🆕 메뉴 OBJID
}) => { }) => {
const [rightTableOpen, setRightTableOpen] = useState(false); const [rightTableOpen, setRightTableOpen] = useState(false);
const [leftColumnOpen, setLeftColumnOpen] = useState(false); const [leftColumnOpen, setLeftColumnOpen] = useState(false);
@ -212,9 +214,26 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
type EntityRefTable = { tableName: string; columns: ColumnInfo[] }; type EntityRefTable = { tableName: string; columns: ColumnInfo[] };
const [entityReferenceTables, setEntityReferenceTables] = useState<Record<string, EntityRefTable[]>>({}); 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"; 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(() => { useEffect(() => {
if (relationshipType === "join") { if (relationshipType === "join") {
@ -568,8 +587,15 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
<div className="space-y-2"> <div className="space-y-2">
<Label> </Label> <Label> </Label>
<Input <Input
value={config.leftPanel?.title || ""} value={localTitles.left}
onChange={(e) => updateLeftPanel({ title: e.target.value })} onChange={(e) => {
setIsUserEditing(true);
setLocalTitles(prev => ({ ...prev, left: e.target.value }));
}}
onBlur={() => {
setIsUserEditing(false);
updateLeftPanel({ title: localTitles.left });
}}
placeholder="좌측 패널 제목" placeholder="좌측 패널 제목"
/> />
</div> </div>
@ -1345,6 +1371,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
} as any))} } as any))}
config={config.leftPanel?.dataFilter} config={config.leftPanel?.dataFilter}
onConfigChange={(dataFilter) => updateLeftPanel({ dataFilter })} onConfigChange={(dataFilter) => updateLeftPanel({ dataFilter })}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
/> />
</div> </div>
@ -1355,8 +1382,15 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
<div className="space-y-2"> <div className="space-y-2">
<Label> </Label> <Label> </Label>
<Input <Input
value={config.rightPanel?.title || ""} value={localTitles.right}
onChange={(e) => updateRightPanel({ title: e.target.value })} onChange={(e) => {
setIsUserEditing(true);
setLocalTitles(prev => ({ ...prev, right: e.target.value }));
}}
onBlur={() => {
setIsUserEditing(false);
updateRightPanel({ title: localTitles.right });
}}
placeholder="우측 패널 제목" placeholder="우측 패널 제목"
/> />
</div> </div>
@ -2270,6 +2304,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
} as any))} } as any))}
config={config.rightPanel?.dataFilter} config={config.rightPanel?.dataFilter}
onConfigChange={(dataFilter) => updateRightPanel({ dataFilter })} onConfigChange={(dataFilter) => updateRightPanel({ dataFilter })}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
/> />
</div> </div>

View File

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

View File

@ -11,6 +11,7 @@ import { FilterPanel } from "@/components/screen/table-options/FilterPanel";
import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel"; import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
import { TableFilter } from "@/types/table-options"; import { TableFilter } from "@/types/table-options";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ModernDatePicker } from "@/components/screen/filters/ModernDatePicker";
interface PresetFilter { interface PresetFilter {
id: string; id: string;
@ -62,7 +63,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// 활성화된 필터 목록 // 활성화된 필터 목록
const [activeFilters, setActiveFilters] = useState<TableFilter[]>([]); const [activeFilters, setActiveFilters] = useState<TableFilter[]>([]);
const [filterValues, setFilterValues] = useState<Record<string, string>>({}); const [filterValues, setFilterValues] = useState<Record<string, any>>({});
// select 타입 필터의 옵션들 // select 타입 필터의 옵션들
const [selectOptions, setSelectOptions] = useState<Record<string, Array<{ label: string; value: string }>>>({}); 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 hasMultipleTables = tableList.length > 1;
// 필터 값 변경 핸들러 // 필터 값 변경 핸들러
const handleFilterChange = (columnName: string, value: string) => { const handleFilterChange = (columnName: string, value: any) => {
const newValues = { const newValues = {
...filterValues, ...filterValues,
[columnName]: value, [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 const filtersWithValues = activeFilters
.map((filter) => ({ .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, ...filter,
value: values[filter.columnName] || "", value: filterValue || "",
})) };
.filter((f) => f.value !== ""); })
.filter((f) => {
// 빈 값 체크
if (!f.value) return false;
if (typeof f.value === "string" && f.value === "") return false;
return true;
});
currentTable?.onFilterChange(filtersWithValues); currentTable?.onFilterChange(filtersWithValues);
}; };
@ -271,14 +309,21 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
switch (filter.filterType) { switch (filter.filterType) {
case "date": case "date":
return ( return (
<Input <div style={{ width: `${width}px` }}>
type="date" <ModernDatePicker
value={value} label={column?.columnLabel || filter.columnName}
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)} value={value ? (typeof value === 'string' ? { from: new Date(value), to: new Date(value) } : value) : {}}
className="h-9 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm" onChange={(dateRange) => {
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }} if (dateRange.from && dateRange.to) {
placeholder={column?.columnLabel} // 기간이 선택되면 from과 to를 모두 저장
handleFilterChange(filter.columnName, dateRange);
} else {
handleFilterChange(filter.columnName, "");
}
}}
includeTime={false}
/> />
</div>
); );
case "number": case "number":