Compare commits

...

6 Commits

24 changed files with 1360 additions and 586 deletions

View File

@ -3245,6 +3245,7 @@ export const resetUserPassword = async (
/** /**
* ( ) * ( )
* column_labels
*/ */
export async function getTableSchema( export async function getTableSchema(
req: AuthenticatedRequest, req: AuthenticatedRequest,
@ -3264,20 +3265,25 @@ export async function getTableSchema(
logger.info("테이블 스키마 조회", { tableName, companyCode }); logger.info("테이블 스키마 조회", { tableName, companyCode });
// information_schema에서 컬럼 정보 가져오기 // information_schema와 column_labels를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기
const schemaQuery = ` const schemaQuery = `
SELECT SELECT
column_name, ic.column_name,
data_type, ic.data_type,
is_nullable, ic.is_nullable,
column_default, ic.column_default,
character_maximum_length, ic.character_maximum_length,
numeric_precision, ic.numeric_precision,
numeric_scale ic.numeric_scale,
FROM information_schema.columns cl.column_label,
WHERE table_schema = 'public' cl.display_order
AND table_name = $1 FROM information_schema.columns ic
ORDER BY ordinal_position LEFT JOIN column_labels cl
ON cl.table_name = ic.table_name
AND cl.column_name = ic.column_name
WHERE ic.table_schema = 'public'
AND ic.table_name = $1
ORDER BY COALESCE(cl.display_order, ic.ordinal_position), ic.ordinal_position
`; `;
const columns = await query<any>(schemaQuery, [tableName]); const columns = await query<any>(schemaQuery, [tableName]);
@ -3290,9 +3296,10 @@ export async function getTableSchema(
return; return;
} }
// 컬럼 정보를 간단한 형태로 변환 // 컬럼 정보를 간단한 형태로 변환 (라벨 정보 포함)
const columnList = columns.map((col: any) => ({ const columnList = columns.map((col: any) => ({
name: col.column_name, name: col.column_name,
label: col.column_label || col.column_name, // 라벨이 없으면 컬럼명 사용
type: col.data_type, type: col.data_type,
nullable: col.is_nullable === "YES", nullable: col.is_nullable === "YES",
default: col.column_default, default: col.column_default,

View File

@ -18,10 +18,11 @@ import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRendere
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보 import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보
import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지 import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션 import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 테이블 옵션
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리 import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 높이 관리
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 🆕 컴포넌트 간 통신 import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 컴포넌트 간 통신
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 🆕 분할 패널 리사이즈 import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 분할 패널 리사이즈
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리
function ScreenViewPage() { function ScreenViewPage() {
const params = useParams(); const params = useParams();
@ -307,7 +308,8 @@ function ScreenViewPage() {
return ( return (
<ScreenPreviewProvider isPreviewMode={false}> <ScreenPreviewProvider isPreviewMode={false}>
<TableOptionsProvider> <ActiveTabProvider>
<TableOptionsProvider>
<div ref={containerRef} className="bg-background h-full w-full overflow-auto p-3"> <div ref={containerRef} className="bg-background h-full w-full overflow-auto p-3">
{/* 레이아웃 준비 중 로딩 표시 */} {/* 레이아웃 준비 중 로딩 표시 */}
{!layoutReady && ( {!layoutReady && (
@ -786,7 +788,8 @@ function ScreenViewPage() {
}} }}
/> />
</div> </div>
</TableOptionsProvider> </TableOptionsProvider>
</ActiveTabProvider>
</ScreenPreviewProvider> </ScreenPreviewProvider>
); );
} }

View File

@ -29,7 +29,6 @@ import {
Plus, Plus,
Minus, Minus,
ArrowRight, ArrowRight,
Save,
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport"; import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
@ -52,12 +51,6 @@ interface ColumnMapping {
systemColumn: string | null; systemColumn: string | null;
} }
interface UploadConfig {
name: string;
type: string;
mappings: ColumnMapping[];
}
export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
open, open,
onOpenChange, onOpenChange,
@ -88,8 +81,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const [excelColumns, setExcelColumns] = useState<string[]>([]); const [excelColumns, setExcelColumns] = useState<string[]>([]);
const [systemColumns, setSystemColumns] = useState<TableColumn[]>([]); const [systemColumns, setSystemColumns] = useState<TableColumn[]>([]);
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]); const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
const [configName, setConfigName] = useState<string>("");
const [configType, setConfigType] = useState<string>("");
// 4단계: 확인 // 4단계: 확인
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
@ -114,7 +105,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const data = await importFromExcel(selectedFile, sheets[0]); const data = await importFromExcel(selectedFile, sheets[0]);
setAllData(data); setAllData(data);
setDisplayData(data.slice(0, 10)); setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능)
if (data.length > 0) { if (data.length > 0) {
const columns = Object.keys(data[0]); const columns = Object.keys(data[0]);
@ -139,7 +130,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
try { try {
const data = await importFromExcel(file, sheetName); const data = await importFromExcel(file, sheetName);
setAllData(data); setAllData(data);
setDisplayData(data.slice(0, 10)); setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능)
if (data.length > 0) { if (data.length > 0) {
const columns = Object.keys(data[0]); const columns = Object.keys(data[0]);
@ -236,13 +227,23 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
} }
}; };
// 자동 매핑 // 자동 매핑 - 컬럼명과 라벨 모두 비교
const handleAutoMapping = () => { const handleAutoMapping = () => {
const newMappings = excelColumns.map((excelCol) => { const newMappings = excelColumns.map((excelCol) => {
const matchedSystemCol = systemColumns.find( const normalizedExcelCol = excelCol.toLowerCase().trim();
(sysCol) => sysCol.name.toLowerCase() === excelCol.toLowerCase()
// 1. 먼저 라벨로 매칭 시도
let matchedSystemCol = systemColumns.find(
(sysCol) => sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol
); );
// 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도
if (!matchedSystemCol) {
matchedSystemCol = systemColumns.find(
(sysCol) => sysCol.name.toLowerCase().trim() === normalizedExcelCol
);
}
return { return {
excelColumn: excelCol, excelColumn: excelCol,
systemColumn: matchedSystemCol ? matchedSystemCol.name : null, systemColumn: matchedSystemCol ? matchedSystemCol.name : null,
@ -265,28 +266,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
); );
}; };
// 설정 저장
const handleSaveConfig = () => {
if (!configName.trim()) {
toast.error("거래처명을 입력해주세요.");
return;
}
const config: UploadConfig = {
name: configName,
type: configType,
mappings: columnMappings,
};
const savedConfigs = JSON.parse(
localStorage.getItem("excelUploadConfigs") || "[]"
);
savedConfigs.push(config);
localStorage.setItem("excelUploadConfigs", JSON.stringify(savedConfigs));
toast.success("설정이 저장되었습니다.");
};
// 다음 단계 // 다음 단계
const handleNext = () => { const handleNext = () => {
if (currentStep === 1 && !file) { if (currentStep === 1 && !file) {
@ -317,7 +296,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
setIsUploading(true); setIsUploading(true);
try { try {
const mappedData = displayData.map((row) => { // allData를 사용하여 전체 데이터 업로드 (displayData는 미리보기용 10개만)
const mappedData = allData.map((row) => {
const mappedRow: Record<string, any> = {}; const mappedRow: Record<string, any> = {};
columnMappings.forEach((mapping) => { columnMappings.forEach((mapping) => {
if (mapping.systemColumn) { if (mapping.systemColumn) {
@ -379,8 +359,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
setExcelColumns([]); setExcelColumns([]);
setSystemColumns([]); setSystemColumns([]);
setColumnMappings([]); setColumnMappings([]);
setConfigName("");
setConfigType("");
} }
}, [open]); }, [open]);
@ -689,27 +667,25 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
</div> </div>
)} )}
{/* 3단계: 컬럼 매핑 - 3단 레이아웃 */} {/* 3단계: 컬럼 매핑 */}
{currentStep === 3 && ( {currentStep === 3 && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[2fr_3fr_2fr]"> <div className="space-y-4">
{/* 왼쪽: 컬럼 매핑 설정 제목 + 자동 매핑 버튼 */} {/* 상단: 제목 + 자동 매핑 버튼 */}
<div className="space-y-4"> <div className="flex items-center justify-between">
<div> <h3 className="text-sm font-semibold sm:text-base"> </h3>
<h3 className="mb-3 text-sm font-semibold sm:text-base"> </h3> <Button
<Button type="button"
type="button" variant="default"
variant="default" size="sm"
size="sm" onClick={handleAutoMapping}
onClick={handleAutoMapping} className="h-8 text-xs sm:h-9 sm:text-sm"
className="h-8 w-full text-xs sm:h-9 sm:text-sm" >
> <Zap className="mr-2 h-4 w-4" />
<Zap className="mr-2 h-4 w-4" />
</Button>
</Button>
</div>
</div> </div>
{/* 중앙: 매핑 리스트 */} {/* 매핑 리스트 */}
<div className="space-y-2"> <div className="space-y-2">
<div className="grid grid-cols-[1fr_auto_1fr] gap-2 text-[10px] font-medium text-muted-foreground sm:text-xs"> <div className="grid grid-cols-[1fr_auto_1fr] gap-2 text-[10px] font-medium text-muted-foreground sm:text-xs">
<div> </div> <div> </div>
@ -734,7 +710,14 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
} }
> >
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm"> <SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="매핑 안함" /> <SelectValue placeholder="매핑 안함">
{mapping.systemColumn
? (() => {
const col = systemColumns.find(c => c.name === mapping.systemColumn);
return col?.label || mapping.systemColumn;
})()
: "매핑 안함"}
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none" className="text-xs sm:text-sm"> <SelectItem value="none" className="text-xs sm:text-sm">
@ -746,7 +729,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
value={col.name} value={col.name}
className="text-xs sm:text-sm" className="text-xs sm:text-sm"
> >
{col.name} ({col.type}) {col.label || col.name} ({col.type})
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -755,50 +738,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
))} ))}
</div> </div>
</div> </div>
{/* 오른쪽: 현재 설정 저장 */}
<div className="rounded-md border border-border bg-muted/30 p-4">
<div className="mb-4 flex items-center gap-2">
<Save className="h-4 w-4" />
<h3 className="text-sm font-semibold sm:text-base"> </h3>
</div>
<div className="space-y-3">
<div>
<Label htmlFor="config-name" className="text-[10px] sm:text-xs">
*
</Label>
<Input
id="config-name"
value={configName}
onChange={(e) => setConfigName(e.target.value)}
placeholder="거래처 선택"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="config-type" className="text-[10px] sm:text-xs">
</Label>
<Input
id="config-type"
value={configType}
onChange={(e) => setConfigType(e.target.value)}
placeholder="유형을 입력하세요 (예: 원자재)"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<Button
type="button"
variant="default"
size="sm"
onClick={handleSaveConfig}
className="h-8 w-full text-xs sm:h-9 sm:text-sm"
>
<Save className="mr-2 h-3 w-3" />
</Button>
</div>
</div>
</div> </div>
)} )}
@ -815,7 +754,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
<span className="font-medium">:</span> {selectedSheet} <span className="font-medium">:</span> {selectedSheet}
</p> </p>
<p> <p>
<span className="font-medium"> :</span> {displayData.length} <span className="font-medium"> :</span> {allData.length}
</p> </p>
<p> <p>
<span className="font-medium">:</span> {tableName} <span className="font-medium">:</span> {tableName}

View File

@ -12,6 +12,7 @@ import { useAuth } from "@/hooks/useAuth";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext"; import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
interface ScreenModalState { interface ScreenModalState {
isOpen: boolean; isOpen: boolean;
@ -666,6 +667,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</div> </div>
</div> </div>
) : screenData ? ( ) : screenData ? (
<ActiveTabProvider>
<TableOptionsProvider> <TableOptionsProvider>
<div <div
className="relative mx-auto bg-white" className="relative mx-auto bg-white"
@ -738,6 +740,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
})} })}
</div> </div>
</TableOptionsProvider> </TableOptionsProvider>
</ActiveTabProvider>
) : ( ) : (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<p className="text-muted-foreground"> .</p> <p className="text-muted-foreground"> .</p>

View File

@ -51,6 +51,7 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar"; import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
/** /**
* 🔗 * 🔗
@ -2103,7 +2104,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
return ( return (
<SplitPanelProvider> <SplitPanelProvider>
<TableOptionsProvider> <ActiveTabProvider>
<TableOptionsProvider>
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* 테이블 옵션 툴바 */} {/* 테이블 옵션 툴바 */}
<TableOptionsToolbar /> <TableOptionsToolbar />
@ -2210,7 +2212,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</TableOptionsProvider> </TableOptionsProvider>
</ActiveTabProvider>
</SplitPanelProvider> </SplitPanelProvider>
); );
}; };

View File

@ -39,22 +39,25 @@ interface InteractiveScreenViewerProps {
id: number; id: number;
tableName?: string; tableName?: string;
}; };
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용) menuObjid?: number; // 메뉴 OBJID (코드 스코프용)
onSave?: () => Promise<void>; onSave?: () => Promise<void>;
onRefresh?: () => void; onRefresh?: () => void;
onFlowRefresh?: () => void; onFlowRefresh?: () => void;
// 🆕 외부에서 전달받는 사용자 정보 (ScreenModal 등에서 사용) // 외부에서 전달받는 사용자 정보 (ScreenModal 등에서 사용)
userId?: string; userId?: string;
userName?: string; userName?: string;
companyCode?: string; companyCode?: string;
// 🆕 그룹 데이터 (EditModal에서 전달) // 그룹 데이터 (EditModal에서 전달)
groupedData?: Record<string, any>[]; groupedData?: Record<string, any>[];
// 🆕 비활성화할 필드 목록 (EditModal에서 전달) // 비활성화할 필드 목록 (EditModal에서 전달)
disabledFields?: string[]; disabledFields?: string[];
// 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록) // EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
isInModal?: boolean; isInModal?: boolean;
// 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용) // 원본 데이터 (수정 모드에서 UPDATE 판단용)
originalData?: Record<string, any> | null; originalData?: Record<string, any> | null;
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
parentTabId?: string; // 부모 탭 ID
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
} }
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
@ -74,7 +77,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
groupedData, groupedData,
disabledFields = [], disabledFields = [],
isInModal = false, isInModal = false,
originalData, // 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용) originalData,
parentTabId,
parentTabsComponentId,
}) => { }) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const { userName: authUserName, user: authUser } = useAuth(); const { userName: authUserName, user: authUser } = useAuth();
@ -359,43 +364,43 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
component={comp} component={comp}
isInteractive={true} isInteractive={true}
formData={formData} formData={formData}
originalData={originalData || undefined} // 🆕 원본 데이터 전달 (UPDATE 판단용) originalData={originalData || undefined}
onFormDataChange={handleFormDataChange} onFormDataChange={handleFormDataChange}
screenId={screenInfo?.id} screenId={screenInfo?.id}
tableName={screenInfo?.tableName} tableName={screenInfo?.tableName}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 menuObjid={menuObjid}
userId={user?.userId} // ✅ 사용자 ID 전달 userId={user?.userId}
userName={user?.userName} // ✅ 사용자 이름 전달 userName={user?.userName}
companyCode={user?.companyCode} // ✅ 회사 코드 전달 companyCode={user?.companyCode}
onSave={onSave} // 🆕 EditModal의 handleSave 콜백 전달 onSave={onSave}
allComponents={allComponents} // 🆕 같은 화면의 모든 컴포넌트 전달 (TableList 자동 감지용) allComponents={allComponents}
selectedRowsData={selectedRowsData} selectedRowsData={selectedRowsData}
onSelectedRowsChange={(selectedRows, selectedData) => { onSelectedRowsChange={(selectedRows, selectedData) => {
console.log("🔍 테이블에서 선택된 행 데이터:", selectedData); console.log("테이블에서 선택된 행 데이터:", selectedData);
setSelectedRowsData(selectedData); setSelectedRowsData(selectedData);
}} }}
// 🆕 그룹 데이터 전달 (EditModal → ModalRepeaterTable)
groupedData={groupedData} groupedData={groupedData}
// 🆕 비활성화 필드 전달 (EditModal → 각 컴포넌트)
disabledFields={disabledFields} disabledFields={disabledFields}
flowSelectedData={flowSelectedData} flowSelectedData={flowSelectedData}
flowSelectedStepId={flowSelectedStepId} flowSelectedStepId={flowSelectedStepId}
onFlowSelectedDataChange={(selectedData, stepId) => { onFlowSelectedDataChange={(selectedData, stepId) => {
console.log("🔍 플로우에서 선택된 데이터:", { selectedData, stepId }); console.log("플로우에서 선택된 데이터:", { selectedData, stepId });
setFlowSelectedData(selectedData); setFlowSelectedData(selectedData);
setFlowSelectedStepId(stepId); setFlowSelectedStepId(stepId);
}} }}
onRefresh={ onRefresh={
onRefresh || onRefresh ||
(() => { (() => {
// 부모로부터 전달받은 onRefresh 또는 기본 동작 console.log("InteractiveScreenViewerDynamic onRefresh 호출");
console.log("🔄 InteractiveScreenViewerDynamic onRefresh 호출");
}) })
} }
onFlowRefresh={onFlowRefresh} onFlowRefresh={onFlowRefresh}
onClose={() => { onClose={() => {
// buttonActions.ts가 이미 처리함 // buttonActions.ts가 이미 처리함
}} }}
// 탭 관련 정보 전달
parentTabId={parentTabId}
parentTabsComponentId={parentTabsComponentId}
/> />
); );
} }

View File

@ -958,6 +958,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length, characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
codeCategory: col.codeCategory || col.code_category, codeCategory: col.codeCategory || col.code_category,
codeValue: col.codeValue || col.code_value, codeValue: col.codeValue || col.code_value,
// 엔티티 타입용 참조 테이블 정보
referenceTable: col.referenceTable || col.reference_table,
referenceColumn: col.referenceColumn || col.reference_column,
displayColumn: col.displayColumn || col.display_column,
}; };
}); });

View File

@ -6,18 +6,10 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button"; import { Database, Search, Info } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { Database, Search, Plus, Trash2 } from "lucide-react";
import { WebTypeConfigPanelProps } from "@/lib/registry/types"; import { WebTypeConfigPanelProps } from "@/lib/registry/types";
import { WidgetComponent, EntityTypeConfig } from "@/types/screen"; import { WidgetComponent, EntityTypeConfig } from "@/types/screen";
import { tableTypeApi } from "@/lib/api/screen";
interface EntityField {
name: string;
label: string;
type: string;
visible: boolean;
}
export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
component, component,
@ -27,16 +19,31 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
const widget = component as WidgetComponent; const widget = component as WidgetComponent;
const config = (widget.webTypeConfig as EntityTypeConfig) || {}; const config = (widget.webTypeConfig as EntityTypeConfig) || {};
// 로컬 상태 // 테이블 타입 관리에서 설정된 참조 테이블 정보
const [referenceInfo, setReferenceInfo] = useState<{
referenceTable: string;
referenceColumn: string;
displayColumn: string;
isLoading: boolean;
error: string | null;
}>({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: true,
error: null,
});
// 로컬 상태 (UI 관련 설정만)
const [localConfig, setLocalConfig] = useState<EntityTypeConfig>({ const [localConfig, setLocalConfig] = useState<EntityTypeConfig>({
entityType: config.entityType || "", entityType: config.entityType || "",
displayFields: config.displayFields || [], displayFields: config.displayFields || [],
searchFields: config.searchFields || [], searchFields: config.searchFields || [],
valueField: config.valueField || "id", valueField: config.valueField || "",
labelField: config.labelField || "name", labelField: config.labelField || "",
multiple: config.multiple || false, multiple: config.multiple || false,
searchable: config.searchable !== false, // 기본값 true searchable: config.searchable !== false,
placeholder: config.placeholder || "엔티티를 선택하세요", placeholder: config.placeholder || "항목을 선택하세요",
emptyMessage: config.emptyMessage || "검색 결과가 없습니다", emptyMessage: config.emptyMessage || "검색 결과가 없습니다",
pageSize: config.pageSize || 20, pageSize: config.pageSize || 20,
minSearchLength: config.minSearchLength || 1, minSearchLength: config.minSearchLength || 1,
@ -47,10 +54,95 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
filters: config.filters || {}, filters: config.filters || {},
}); });
// 새 필드 추가용 상태 // 테이블 타입 관리에서 설정된 참조 테이블 정보 로드
const [newFieldName, setNewFieldName] = useState(""); useEffect(() => {
const [newFieldLabel, setNewFieldLabel] = useState(""); const loadReferenceInfo = async () => {
const [newFieldType, setNewFieldType] = useState("string"); // 컴포넌트의 테이블명과 컬럼명이 있는 경우에만 조회
const tableName = widget.tableName;
const columnName = widget.columnName;
if (!tableName || !columnName) {
setReferenceInfo({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: false,
error: "테이블 또는 컬럼 정보가 없습니다.",
});
return;
}
try {
// 테이블 타입 관리에서 컬럼 정보 조회
const columns = await tableTypeApi.getColumns(tableName);
const columnInfo = columns.find((col: any) =>
(col.columnName || col.column_name) === columnName
);
if (columnInfo) {
const refTable = columnInfo.referenceTable || columnInfo.reference_table || "";
const refColumn = columnInfo.referenceColumn || columnInfo.reference_column || "";
const dispColumn = columnInfo.displayColumn || columnInfo.display_column || "";
// detailSettings에서도 정보 확인 (JSON 파싱)
let detailSettings: any = {};
if (columnInfo.detailSettings) {
try {
if (typeof columnInfo.detailSettings === 'string') {
detailSettings = JSON.parse(columnInfo.detailSettings);
} else {
detailSettings = columnInfo.detailSettings;
}
} catch {
// JSON 파싱 실패 시 무시
}
}
const finalRefTable = refTable || detailSettings.referenceTable || "";
const finalRefColumn = refColumn || detailSettings.referenceColumn || "";
const finalDispColumn = dispColumn || detailSettings.displayColumn || "";
setReferenceInfo({
referenceTable: finalRefTable,
referenceColumn: finalRefColumn,
displayColumn: finalDispColumn,
isLoading: false,
error: null,
});
// webTypeConfig에 참조 테이블 정보 자동 설정
if (finalRefTable) {
const newConfig = {
...localConfig,
valueField: finalRefColumn || "id",
labelField: finalDispColumn || "name",
};
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
}
} else {
setReferenceInfo({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: false,
error: "컬럼 정보를 찾을 수 없습니다.",
});
}
} catch (error) {
console.error("참조 테이블 정보 로드 실패:", error);
setReferenceInfo({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: false,
error: "참조 테이블 정보 로드 실패",
});
}
};
loadReferenceInfo();
}, [widget.tableName, widget.columnName]);
// 컴포넌트 변경 시 로컬 상태 동기화 // 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => { useEffect(() => {
@ -59,11 +151,11 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
entityType: currentConfig.entityType || "", entityType: currentConfig.entityType || "",
displayFields: currentConfig.displayFields || [], displayFields: currentConfig.displayFields || [],
searchFields: currentConfig.searchFields || [], searchFields: currentConfig.searchFields || [],
valueField: currentConfig.valueField || "id", valueField: currentConfig.valueField || referenceInfo.referenceColumn || "",
labelField: currentConfig.labelField || "name", labelField: currentConfig.labelField || referenceInfo.displayColumn || "",
multiple: currentConfig.multiple || false, multiple: currentConfig.multiple || false,
searchable: currentConfig.searchable !== false, searchable: currentConfig.searchable !== false,
placeholder: currentConfig.placeholder || "엔티티를 선택하세요", placeholder: currentConfig.placeholder || "항목을 선택하세요",
emptyMessage: currentConfig.emptyMessage || "검색 결과가 없습니다", emptyMessage: currentConfig.emptyMessage || "검색 결과가 없습니다",
pageSize: currentConfig.pageSize || 20, pageSize: currentConfig.pageSize || 20,
minSearchLength: currentConfig.minSearchLength || 1, minSearchLength: currentConfig.minSearchLength || 1,
@ -73,7 +165,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
apiEndpoint: currentConfig.apiEndpoint || "", apiEndpoint: currentConfig.apiEndpoint || "",
filters: currentConfig.filters || {}, filters: currentConfig.filters || {},
}); });
}, [widget.webTypeConfig]); }, [widget.webTypeConfig, referenceInfo.referenceColumn, referenceInfo.displayColumn]);
// 설정 업데이트 핸들러 (즉시 부모에게 전달 - 드롭다운, 체크박스 등) // 설정 업데이트 핸들러 (즉시 부모에게 전달 - 드롭다운, 체크박스 등)
const updateConfig = (field: keyof EntityTypeConfig, value: any) => { const updateConfig = (field: keyof EntityTypeConfig, value: any) => {
@ -92,89 +184,6 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
onUpdateProperty("webTypeConfig", localConfig); onUpdateProperty("webTypeConfig", localConfig);
}; };
// 필드 추가
const addDisplayField = () => {
if (!newFieldName.trim() || !newFieldLabel.trim()) return;
const newField: EntityField = {
name: newFieldName.trim(),
label: newFieldLabel.trim(),
type: newFieldType,
visible: true,
};
const newFields = [...localConfig.displayFields, newField];
updateConfig("displayFields", newFields);
setNewFieldName("");
setNewFieldLabel("");
setNewFieldType("string");
};
// 필드 제거
const removeDisplayField = (index: number) => {
const newFields = localConfig.displayFields.filter((_, i) => i !== index);
updateConfig("displayFields", newFields);
};
// 필드 업데이트 (입력 중) - 로컬 상태만 업데이트
const updateDisplayField = (index: number, field: keyof EntityField, value: any) => {
const newFields = [...localConfig.displayFields];
newFields[index] = { ...newFields[index], [field]: value };
setLocalConfig({ ...localConfig, displayFields: newFields });
};
// 필드 업데이트 완료 (onBlur) - 부모에게 전달
const handleFieldBlur = () => {
onUpdateProperty("webTypeConfig", localConfig);
};
// 검색 필드 토글
const toggleSearchField = (fieldName: string) => {
const currentSearchFields = localConfig.searchFields || [];
const newSearchFields = currentSearchFields.includes(fieldName)
? currentSearchFields.filter((f) => f !== fieldName)
: [...currentSearchFields, fieldName];
updateConfig("searchFields", newSearchFields);
};
// 기본 엔티티 타입들
const commonEntityTypes = [
{ value: "user", label: "사용자", fields: ["id", "name", "email", "department"] },
{ value: "department", label: "부서", fields: ["id", "name", "code", "parentId"] },
{ value: "product", label: "제품", fields: ["id", "name", "code", "category", "price"] },
{ value: "customer", label: "고객", fields: ["id", "name", "company", "contact"] },
{ value: "project", label: "프로젝트", fields: ["id", "name", "status", "manager", "startDate"] },
];
// 기본 엔티티 타입 적용
const applyEntityType = (entityType: string) => {
const entityConfig = commonEntityTypes.find((e) => e.value === entityType);
if (!entityConfig) return;
updateConfig("entityType", entityType);
updateConfig("apiEndpoint", `/api/entities/${entityType}`);
const defaultFields: EntityField[] = entityConfig.fields.map((field) => ({
name: field,
label: field.charAt(0).toUpperCase() + field.slice(1),
type: field.includes("Date") ? "date" : field.includes("price") || field.includes("Id") ? "number" : "string",
visible: true,
}));
updateConfig("displayFields", defaultFields);
updateConfig("searchFields", [entityConfig.fields[1] || "name"]); // 두 번째 필드를 기본 검색 필드로
};
// 필드 타입 옵션
const fieldTypes = [
{ value: "string", label: "문자열" },
{ value: "number", label: "숫자" },
{ value: "date", label: "날짜" },
{ value: "boolean", label: "불린" },
{ value: "email", label: "이메일" },
{ value: "url", label: "URL" },
];
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@ -182,12 +191,70 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Database className="h-4 w-4" /> <Database className="h-4 w-4" />
</CardTitle> </CardTitle>
<CardDescription className="text-xs"> .</CardDescription> <CardDescription className="text-xs">
.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* 기본 설정 */} {/* 참조 테이블 정보 (테이블 타입 관리에서 설정된 값 - 읽기 전용) */}
<div className="space-y-3"> <div className="space-y-3">
<h4 className="text-sm font-medium"> </h4> <h4 className="text-sm font-medium flex items-center gap-2">
<span className="bg-muted text-muted-foreground px-1.5 py-0.5 rounded text-[10px]">
</span>
</h4>
{referenceInfo.isLoading ? (
<div className="bg-muted/50 rounded-md border p-3">
<p className="text-xs text-muted-foreground"> ...</p>
</div>
) : referenceInfo.error ? (
<div className="bg-destructive/10 rounded-md border border-destructive/20 p-3">
<p className="text-xs text-destructive flex items-center gap-1">
<Info className="h-3 w-3" />
{referenceInfo.error}
</p>
<p className="text-[10px] text-muted-foreground mt-1">
.
</p>
</div>
) : !referenceInfo.referenceTable ? (
<div className="bg-amber-500/10 rounded-md border border-amber-500/20 p-3">
<p className="text-xs text-amber-700 flex items-center gap-1">
<Info className="h-3 w-3" />
.
</p>
<p className="text-[10px] text-muted-foreground mt-1">
.
</p>
</div>
) : (
<div className="bg-muted/50 rounded-md border p-3 space-y-2">
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<span className="text-muted-foreground"> :</span>
<div className="font-medium">{referenceInfo.referenceTable}</div>
</div>
<div>
<span className="text-muted-foreground"> :</span>
<div className="font-medium">{referenceInfo.referenceColumn || "-"}</div>
</div>
<div>
<span className="text-muted-foreground"> :</span>
<div className="font-medium">{referenceInfo.displayColumn || "-"}</div>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
.
</p>
</div>
)}
</div>
{/* UI 모드 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium">UI </h4>
{/* UI 모드 선택 */} {/* UI 모드 선택 */}
<div className="space-y-2"> <div className="space-y-2">
@ -216,208 +283,6 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</p> </p>
</div> </div>
<div className="space-y-2">
<Label htmlFor="entityType" className="text-xs">
</Label>
<Input
id="entityType"
value={localConfig.entityType || ""}
onChange={(e) => updateConfigLocal("entityType", e.target.value)}
onBlur={handleInputBlur}
placeholder="user, product, department..."
className="text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<div className="grid grid-cols-2 gap-2">
{commonEntityTypes.map((entity) => (
<Button
key={entity.value}
size="sm"
variant="outline"
onClick={() => applyEntityType(entity.value)}
className="text-xs"
>
{entity.label}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="apiEndpoint" className="text-xs">
API
</Label>
<Input
id="apiEndpoint"
value={localConfig.apiEndpoint || ""}
onChange={(e) => updateConfigLocal("apiEndpoint", e.target.value)}
onBlur={handleInputBlur}
placeholder="/api/entities/user"
className="text-xs"
/>
</div>
</div>
{/* 필드 매핑 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label htmlFor="valueField" className="text-xs">
</Label>
<Input
id="valueField"
value={localConfig.valueField || ""}
onChange={(e) => updateConfigLocal("valueField", e.target.value)}
onBlur={handleInputBlur}
placeholder="id"
className="text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="labelField" className="text-xs">
</Label>
<Input
id="labelField"
value={localConfig.labelField || ""}
onChange={(e) => updateConfigLocal("labelField", e.target.value)}
onBlur={handleInputBlur}
placeholder="name"
className="text-xs"
/>
</div>
</div>
</div>
{/* 표시 필드 관리 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
{/* 새 필드 추가 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<div className="flex gap-2">
<Input
value={newFieldName}
onChange={(e) => setNewFieldName(e.target.value)}
placeholder="필드명"
className="flex-1 text-xs"
/>
<Input
value={newFieldLabel}
onChange={(e) => setNewFieldLabel(e.target.value)}
placeholder="라벨"
className="flex-1 text-xs"
/>
<Select value={newFieldType} onValueChange={setNewFieldType}>
<SelectTrigger className="w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{fieldTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
onClick={addDisplayField}
disabled={!newFieldName.trim() || !newFieldLabel.trim()}
className="text-xs"
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
{/* 현재 필드 목록 */}
<div className="space-y-2">
<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={`${field.name}-${index}`} className="flex items-center gap-2 rounded border p-2">
<Switch
checked={field.visible}
onCheckedChange={(checked) => {
const newFields = [...localConfig.displayFields];
newFields[index] = { ...newFields[index], visible: checked };
const newConfig = { ...localConfig, displayFields: newFields };
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
}}
/>
<Input
value={field.name}
onChange={(e) => updateDisplayField(index, "name", e.target.value)}
onBlur={handleFieldBlur}
placeholder="필드명"
className="flex-1 text-xs"
/>
<Input
value={field.label}
onChange={(e) => updateDisplayField(index, "label", e.target.value)}
onBlur={handleFieldBlur}
placeholder="라벨"
className="flex-1 text-xs"
/>
<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>
<SelectContent>
{fieldTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant={localConfig.searchFields.includes(field.name) ? "default" : "outline"}
onClick={() => toggleSearchField(field.name)}
className="p-1 text-xs"
title={localConfig.searchFields.includes(field.name) ? "검색 필드에서 제거" : "검색 필드로 추가"}
>
<Search className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => removeDisplayField(index)}
className="p-1 text-xs"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
</div>
{/* 검색 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="placeholder" className="text-xs"> <Label htmlFor="placeholder" className="text-xs">
@ -427,7 +292,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
value={localConfig.placeholder || ""} value={localConfig.placeholder || ""}
onChange={(e) => updateConfigLocal("placeholder", e.target.value)} onChange={(e) => updateConfigLocal("placeholder", e.target.value)}
onBlur={handleInputBlur} onBlur={handleInputBlur}
placeholder="엔티티를 선택하세요" placeholder="항목을 선택하세요"
className="text-xs" className="text-xs"
/> />
</div> </div>
@ -445,6 +310,11 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
className="text-xs" className="text-xs"
/> />
</div> </div>
</div>
{/* 검색 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="space-y-2"> <div className="space-y-2">
@ -483,7 +353,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label htmlFor="searchable" className="text-xs"> <Label htmlFor="searchable" className="text-xs">
</Label> </Label>
<p className="text-muted-foreground text-xs"> .</p> <p className="text-muted-foreground text-xs"> .</p>
</div> </div>
<Switch <Switch
id="searchable" id="searchable"
@ -497,7 +367,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label htmlFor="multiple" className="text-xs"> <Label htmlFor="multiple" className="text-xs">
</Label> </Label>
<p className="text-muted-foreground text-xs"> .</p> <p className="text-muted-foreground text-xs"> .</p>
</div> </div>
<Switch <Switch
id="multiple" id="multiple"
@ -507,33 +377,6 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</div> </div>
</div> </div>
{/* 필터 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="space-y-2">
<Label htmlFor="filters" className="text-xs">
JSON
</Label>
<Textarea
id="filters"
value={JSON.stringify(localConfig.filters || {}, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
updateConfig("filters", parsed);
} catch {
// 유효하지 않은 JSON은 무시
}
}}
placeholder='{"status": "active", "department": "IT"}'
className="font-mono text-xs"
rows={3}
/>
<p className="text-muted-foreground text-xs">API JSON .</p>
</div>
</div>
{/* 상태 설정 */} {/* 상태 설정 */}
<div className="space-y-3"> <div className="space-y-3">
<h4 className="text-sm font-medium"> </h4> <h4 className="text-sm font-medium"> </h4>
@ -543,7 +386,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label htmlFor="required" className="text-xs"> <Label htmlFor="required" className="text-xs">
</Label> </Label>
<p className="text-muted-foreground text-xs"> .</p> <p className="text-muted-foreground text-xs"> .</p>
</div> </div>
<Switch <Switch
id="required" id="required"
@ -557,7 +400,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label htmlFor="readonly" className="text-xs"> <Label htmlFor="readonly" className="text-xs">
</Label> </Label>
<p className="text-muted-foreground text-xs"> .</p> <p className="text-muted-foreground text-xs"> .</p>
</div> </div>
<Switch <Switch
id="readonly" id="readonly"
@ -574,31 +417,18 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2 rounded border bg-white p-2"> <div className="flex items-center gap-2 rounded border bg-white p-2">
<Database className="h-4 w-4 text-gray-400" /> <Database className="h-4 w-4 text-gray-400" />
<span className="flex-1 text-xs text-muted-foreground">{localConfig.placeholder || "엔티티를 선택하세요"}</span> <span className="flex-1 text-xs text-muted-foreground">
{localConfig.placeholder || "항목을 선택하세요"}
</span>
{localConfig.searchable && <Search className="h-4 w-4 text-gray-400" />} {localConfig.searchable && <Search className="h-4 w-4 text-gray-400" />}
</div> </div>
{localConfig.displayFields.length > 0 && (
<div className="text-muted-foreground text-xs">
<div className="font-medium"> :</div>
<div className="mt-1 flex flex-wrap gap-1">
{localConfig.displayFields
.filter((f) => f.visible)
.map((field, index) => (
<span key={index} className="rounded bg-gray-100 px-2 py-1">
{field.label}
{localConfig.searchFields.includes(field.name) && " 🔍"}
</span>
))}
</div>
</div>
)}
<div className="text-muted-foreground text-xs"> <div className="text-muted-foreground text-xs">
: {localConfig.entityType || "미정"} : {localConfig.valueField} :{" "} <div>: {referenceInfo.referenceTable || "미설정"}</div>
{localConfig.labelField} <div> : {localConfig.valueField || referenceInfo.referenceColumn || "-"}</div>
{localConfig.multiple && " • 다중선택"} <div> : {localConfig.labelField || referenceInfo.displayColumn || "-"}</div>
{localConfig.required && " • 필수"} {localConfig.multiple && <span> / </span>}
{localConfig.required && <span> / </span>}
</div> </div>
</div> </div>
</div> </div>
@ -609,5 +439,3 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
}; };
EntityConfigPanel.displayName = "EntityConfigPanel"; EntityConfigPanel.displayName = "EntityConfigPanel";

View File

@ -46,6 +46,7 @@ interface DetailSettingsPanelProps {
currentTableName?: string; // 현재 화면의 테이블명 currentTableName?: string; // 현재 화면의 테이블명
tables?: TableInfo[]; // 전체 테이블 목록 tables?: TableInfo[]; // 전체 테이블 목록
currentScreenCompanyCode?: string; // 현재 편집 중인 화면의 회사 코드 currentScreenCompanyCode?: string; // 현재 편집 중인 화면의 회사 코드
components?: ComponentData[]; // 현재 화면의 모든 컴포넌트 (연쇄관계 부모 필드 선택용)
} }
export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
@ -55,6 +56,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
currentTableName, currentTableName,
tables = [], // 기본값 빈 배열 tables = [], // 기본값 빈 배열
currentScreenCompanyCode, currentScreenCompanyCode,
components = [], // 기본값 빈 배열
}) => { }) => {
// 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기 // 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기
const { webTypes } = useWebTypes({ active: "Y" }); const { webTypes } = useWebTypes({ active: "Y" });

View File

@ -7,15 +7,18 @@ import { X, Loader2 } from "lucide-react";
import type { TabsComponent, TabItem } from "@/types/screen-management"; import type { TabsComponent, TabItem } from "@/types/screen-management";
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic"; import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useActiveTab } from "@/contexts/ActiveTabContext";
interface TabsWidgetProps { interface TabsWidgetProps {
component: TabsComponent; component: TabsComponent;
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
menuObjid?: number; // 🆕 부모 화면의 메뉴 OBJID menuObjid?: number; // 부모 화면의 메뉴 OBJID
} }
export function TabsWidget({ component, className, style, menuObjid }: TabsWidgetProps) { export function TabsWidget({ component, className, style, menuObjid }: TabsWidgetProps) {
// ActiveTab context 사용
const { setActiveTab, removeTabsComponent } = useActiveTab();
const { const {
tabs = [], tabs = [],
defaultTab, defaultTab,
@ -51,12 +54,30 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
setVisibleTabs(tabs.filter((tab) => !tab.disabled)); setVisibleTabs(tabs.filter((tab) => !tab.disabled));
}, [tabs]); }, [tabs]);
// 선택된 탭 변경 시 localStorage에 저장 // 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트
useEffect(() => { useEffect(() => {
if (persistSelection && typeof window !== "undefined") { if (persistSelection && typeof window !== "undefined") {
localStorage.setItem(storageKey, selectedTab); localStorage.setItem(storageKey, selectedTab);
} }
}, [selectedTab, persistSelection, storageKey]);
// ActiveTab Context에 현재 활성 탭 정보 등록
const currentTabInfo = visibleTabs.find(t => t.id === selectedTab);
if (currentTabInfo) {
setActiveTab(component.id, {
tabId: selectedTab,
tabsComponentId: component.id,
screenId: currentTabInfo.screenId,
label: currentTabInfo.label,
});
}
}, [selectedTab, persistSelection, storageKey, component.id, visibleTabs, setActiveTab]);
// 컴포넌트 언마운트 시 ActiveTab Context에서 제거
useEffect(() => {
return () => {
removeTabsComponent(component.id);
};
}, [component.id, removeTabsComponent]);
// 초기 로드 시 선택된 탭의 화면 불러오기 // 초기 로드 시 선택된 탭의 화면 불러오기
useEffect(() => { useEffect(() => {
@ -220,16 +241,18 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
margin: "0 auto", margin: "0 auto",
}} }}
> >
{components.map((component: any) => ( {components.map((comp: any) => (
<InteractiveScreenViewerDynamic <InteractiveScreenViewerDynamic
key={component.id} key={comp.id}
component={component} component={comp}
allComponents={components} allComponents={components}
screenInfo={{ screenInfo={{
id: tab.screenId, id: tab.screenId,
tableName: layoutData.tableName, tableName: layoutData.tableName,
}} }}
menuObjid={menuObjid} menuObjid={menuObjid}
parentTabId={tab.id}
parentTabsComponentId={component.id}
/> />
))} ))}
</div> </div>

View File

@ -0,0 +1,139 @@
"use client";
import React, {
createContext,
useContext,
useState,
useCallback,
ReactNode,
} from "react";
/**
*
*/
export interface ActiveTabInfo {
tabId: string; // 탭 고유 ID
tabsComponentId: string; // 부모 탭 컴포넌트 ID
screenId?: number; // 탭에 연결된 화면 ID
label?: string; // 탭 라벨
}
/**
* Context
*/
interface ActiveTabContextValue {
// 현재 활성 탭 정보 (탭 컴포넌트 ID -> 활성 탭 정보)
activeTabs: Map<string, ActiveTabInfo>;
// 활성 탭 설정
setActiveTab: (tabsComponentId: string, tabInfo: ActiveTabInfo) => void;
// 활성 탭 조회
getActiveTab: (tabsComponentId: string) => ActiveTabInfo | undefined;
// 특정 탭 컴포넌트의 활성 탭 ID 조회
getActiveTabId: (tabsComponentId: string) => string | undefined;
// 전체 활성 탭 ID 목록 (모든 탭 컴포넌트에서)
getAllActiveTabIds: () => string[];
// 탭 컴포넌트 제거 시 정리
removeTabsComponent: (tabsComponentId: string) => void;
}
const ActiveTabContext = createContext<ActiveTabContextValue | undefined>(undefined);
export const ActiveTabProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [activeTabs, setActiveTabs] = useState<Map<string, ActiveTabInfo>>(new Map());
/**
*
*/
const setActiveTab = useCallback((tabsComponentId: string, tabInfo: ActiveTabInfo) => {
setActiveTabs((prev) => {
const newMap = new Map(prev);
newMap.set(tabsComponentId, tabInfo);
return newMap;
});
}, []);
/**
*
*/
const getActiveTab = useCallback(
(tabsComponentId: string) => {
return activeTabs.get(tabsComponentId);
},
[activeTabs]
);
/**
* ID
*/
const getActiveTabId = useCallback(
(tabsComponentId: string) => {
return activeTabs.get(tabsComponentId)?.tabId;
},
[activeTabs]
);
/**
* ID
*/
const getAllActiveTabIds = useCallback(() => {
return Array.from(activeTabs.values()).map((info) => info.tabId);
}, [activeTabs]);
/**
*
*/
const removeTabsComponent = useCallback((tabsComponentId: string) => {
setActiveTabs((prev) => {
const newMap = new Map(prev);
newMap.delete(tabsComponentId);
return newMap;
});
}, []);
return (
<ActiveTabContext.Provider
value={{
activeTabs,
setActiveTab,
getActiveTab,
getActiveTabId,
getAllActiveTabIds,
removeTabsComponent,
}}
>
{children}
</ActiveTabContext.Provider>
);
};
/**
* Context Hook
*/
export const useActiveTab = () => {
const context = useContext(ActiveTabContext);
if (!context) {
// Context가 없으면 기본값 반환 (탭이 없는 화면에서 사용 시)
return {
activeTabs: new Map(),
setActiveTab: () => {},
getActiveTab: () => undefined,
getActiveTabId: () => undefined,
getAllActiveTabIds: () => [],
removeTabsComponent: () => {},
};
}
return context;
};
/**
* Optional Context Hook ( undefined )
*/
export const useActiveTabOptional = () => {
return useContext(ActiveTabContext);
};

View File

@ -3,12 +3,14 @@ import React, {
useContext, useContext,
useState, useState,
useCallback, useCallback,
useMemo,
ReactNode, ReactNode,
} from "react"; } from "react";
import { import {
TableRegistration, TableRegistration,
TableOptionsContextValue, TableOptionsContextValue,
} from "@/types/table-options"; } from "@/types/table-options";
import { useActiveTab } from "./ActiveTabContext";
const TableOptionsContext = createContext<TableOptionsContextValue | undefined>( const TableOptionsContext = createContext<TableOptionsContextValue | undefined>(
undefined undefined
@ -89,6 +91,35 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
}); });
}, []); }, []);
// ActiveTab context 사용 (optional - 에러 방지)
const activeTabContext = useActiveTab();
/**
*
*/
const getActiveTabTables = useCallback(() => {
const allTables = Array.from(registeredTables.values());
const activeTabIds = activeTabContext.getAllActiveTabIds();
// 활성 탭이 없으면 탭에 속하지 않은 테이블만 반환
if (activeTabIds.length === 0) {
return allTables.filter(table => !table.parentTabId);
}
// 활성 탭에 속한 테이블 + 탭에 속하지 않은 테이블
return allTables.filter(table =>
!table.parentTabId || activeTabIds.includes(table.parentTabId)
);
}, [registeredTables, activeTabContext]);
/**
*
*/
const getTablesForTab = useCallback((tabId: string) => {
const allTables = Array.from(registeredTables.values());
return allTables.filter(table => table.parentTabId === tabId);
}, [registeredTables]);
return ( return (
<TableOptionsContext.Provider <TableOptionsContext.Provider
value={{ value={{
@ -99,6 +130,8 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
updateTableDataCount, updateTableDataCount,
selectedTableId, selectedTableId,
setSelectedTableId, setSelectedTableId,
getActiveTabTables,
getTablesForTab,
}} }}
> >
{children} {children}

View File

@ -2,6 +2,7 @@ import { apiClient } from "./client";
export interface TableColumn { export interface TableColumn {
name: string; name: string;
label: string; // 컬럼 라벨 (column_labels 테이블에서 가져옴)
type: string; type: string;
nullable: boolean; nullable: boolean;
default: string | null; default: string | null;

View File

@ -132,6 +132,9 @@ export interface DynamicComponentRendererProps {
mode?: "view" | "edit"; mode?: "view" | "edit";
// 모달 내에서 렌더링 여부 // 모달 내에서 렌더링 여부
isInModal?: boolean; isInModal?: boolean;
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
parentTabId?: string; // 부모 탭 ID
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
[key: string]: any; [key: string]: any;
} }

View File

@ -823,28 +823,29 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
display: "grid", display: "grid",
gridTemplateColumns: `repeat(${componentConfig.cardsPerRow || 3}, 1fr)`, // 기본값 3 (한 행당 카드 수) gridTemplateColumns: `repeat(${componentConfig.cardsPerRow || 3}, 1fr)`, // 기본값 3 (한 행당 카드 수)
gridAutoRows: "min-content", // 자동 행 생성으로 모든 데이터 표시 gridAutoRows: "min-content", // 자동 행 생성으로 모든 데이터 표시
gap: `${componentConfig.cardSpacing || 32}px`, // 간격 대폭 증가로 여유로운 느낌 gap: `${componentConfig.cardSpacing || 16}px`, // 카드 간격
padding: "32px", // 패딩 대폭 증가 padding: "16px", // 패딩
width: "100%", width: "100%",
height: "100%", height: "100%",
background: "#f8fafc", // 연한 하늘색 배경 (채도 낮춤) background: "transparent", // 배경색 제거
overflow: "auto", overflow: "auto",
borderRadius: "12px", // 컨테이너 자체도 라운드 처리 borderRadius: "0", // 라운드 제거
}; };
// 카드 스타일 - 컴팩트한 디자인 // 카드 스타일 - 컴팩트한 디자인
const cardStyle: React.CSSProperties = { const cardStyle: React.CSSProperties = {
backgroundColor: "white", backgroundColor: "hsl(var(--card))",
border: "1px solid #e5e7eb", border: "1px solid hsl(var(--border))",
borderRadius: "8px", borderRadius: "8px",
padding: "16px", padding: "16px",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.08)", boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)",
transition: "all 0.2s ease", transition: "all 0.2s ease",
overflow: "hidden", overflow: "hidden",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
position: "relative", position: "relative",
cursor: isDesignMode ? "pointer" : "default", cursor: isDesignMode ? "pointer" : "default",
width: "100%", // 전체 너비 차지
}; };
// 텍스트 자르기 함수 // 텍스트 자르기 함수

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useRef } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Search, X, Check, ChevronsUpDown } from "lucide-react"; import { Search, X, Check, ChevronsUpDown } from "lucide-react";
@ -10,6 +10,7 @@ import { cn } from "@/lib/utils";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { dynamicFormApi } from "@/lib/api/dynamicForm"; import { dynamicFormApi } from "@/lib/api/dynamicForm";
import { cascadingRelationApi } from "@/lib/api/cascadingRelation";
export function EntitySearchInputComponent({ export function EntitySearchInputComponent({
tableName, tableName,
@ -29,6 +30,11 @@ export function EntitySearchInputComponent({
additionalFields = [], additionalFields = [],
className, className,
style, style,
// 연쇄관계 props
cascadingRelationCode,
parentValue: parentValueProp,
parentFieldId,
formData,
// 🆕 추가 props // 🆕 추가 props
component, component,
isInteractive, isInteractive,
@ -38,10 +44,21 @@ export function EntitySearchInputComponent({
component?: any; component?: any;
isInteractive?: boolean; isInteractive?: boolean;
onFormDataChange?: (fieldName: string, value: any) => void; onFormDataChange?: (fieldName: string, value: any) => void;
webTypeConfig?: any; // 웹타입 설정 (연쇄관계 등)
}) { }) {
// uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo" // uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo"
const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete"; const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete";
// 연쇄관계 설정 추출 (webTypeConfig 또는 component.componentConfig에서)
const config = component?.componentConfig || {};
const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode;
const effectiveParentFieldId = parentFieldId || config.parentFieldId;
const effectiveCascadingRole = config.cascadingRole; // "parent" | "child" | undefined
// 부모 역할이면 연쇄관계 로직 적용 안함 (자식만 부모 값에 따라 필터링됨)
const isChildRole = effectiveCascadingRole === "child";
const shouldApplyCascading = effectiveCascadingRelationCode && isChildRole;
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [selectOpen, setSelectOpen] = useState(false); const [selectOpen, setSelectOpen] = useState(false);
const [displayValue, setDisplayValue] = useState(""); const [displayValue, setDisplayValue] = useState("");
@ -50,16 +67,82 @@ export function EntitySearchInputComponent({
const [isLoadingOptions, setIsLoadingOptions] = useState(false); const [isLoadingOptions, setIsLoadingOptions] = useState(false);
const [optionsLoaded, setOptionsLoaded] = useState(false); const [optionsLoaded, setOptionsLoaded] = useState(false);
// 연쇄관계 상태
const [cascadingOptions, setCascadingOptions] = useState<EntitySearchResult[]>([]);
const [isCascadingLoading, setIsCascadingLoading] = useState(false);
const previousParentValue = useRef<any>(null);
// 부모 필드 값 결정 (직접 전달 또는 formData에서 추출) - 자식 역할일 때만 필요
const parentValue = isChildRole
? (parentValueProp ?? (effectiveParentFieldId && formData ? formData[effectiveParentFieldId] : undefined))
: undefined;
// filterCondition을 문자열로 변환하여 비교 (객체 참조 문제 해결) // filterCondition을 문자열로 변환하여 비교 (객체 참조 문제 해결)
const filterConditionKey = JSON.stringify(filterCondition || {}); const filterConditionKey = JSON.stringify(filterCondition || {});
// select 모드일 때 옵션 로드 (한 번만) // 연쇄관계가 설정된 경우: 부모 값이 변경되면 자식 옵션 로드 (자식 역할일 때만)
useEffect(() => { useEffect(() => {
if (mode === "select" && tableName && !optionsLoaded) { const loadCascadingOptions = async () => {
if (!shouldApplyCascading) return;
// 부모 값이 없으면 옵션 초기화
if (!parentValue) {
setCascadingOptions([]);
// 부모 값이 변경되면 현재 값도 초기화
if (previousParentValue.current !== null && previousParentValue.current !== parentValue) {
handleClear();
}
previousParentValue.current = parentValue;
return;
}
// 부모 값이 동일하면 스킵
if (previousParentValue.current === parentValue) {
return;
}
previousParentValue.current = parentValue;
setIsCascadingLoading(true);
try {
console.log("🔗 연쇄관계 옵션 로드:", { effectiveCascadingRelationCode, parentValue });
const response = await cascadingRelationApi.getOptions(effectiveCascadingRelationCode, String(parentValue));
if (response.success && response.data) {
// 옵션을 EntitySearchResult 형태로 변환
const formattedOptions = response.data.map((opt: any) => ({
[valueField]: opt.value,
[displayField]: opt.label,
...opt, // 추가 필드도 포함
}));
setCascadingOptions(formattedOptions);
console.log("✅ 연쇄관계 옵션 로드 완료:", formattedOptions.length, "개");
// 현재 선택된 값이 새 옵션에 없으면 초기화
if (value && !formattedOptions.find((opt: any) => opt[valueField] === value)) {
handleClear();
}
} else {
setCascadingOptions([]);
}
} catch (error) {
console.error("❌ 연쇄관계 옵션 로드 실패:", error);
setCascadingOptions([]);
} finally {
setIsCascadingLoading(false);
}
};
loadCascadingOptions();
}, [shouldApplyCascading, effectiveCascadingRelationCode, parentValue, valueField, displayField]);
// select 모드일 때 옵션 로드 (연쇄관계가 없거나 부모 역할인 경우)
useEffect(() => {
if (mode === "select" && tableName && !optionsLoaded && !shouldApplyCascading) {
loadOptions(); loadOptions();
setOptionsLoaded(true); setOptionsLoaded(true);
} }
}, [mode, tableName, filterConditionKey, optionsLoaded]); }, [mode, tableName, filterConditionKey, optionsLoaded, shouldApplyCascading]);
const loadOptions = async () => { const loadOptions = async () => {
if (!tableName) return; if (!tableName) return;
@ -82,15 +165,19 @@ export function EntitySearchInputComponent({
} }
}; };
// 실제 사용할 옵션 목록 (자식 역할이고 연쇄관계가 있으면 연쇄 옵션 사용)
const effectiveOptions = shouldApplyCascading ? cascadingOptions : options;
const isLoading = shouldApplyCascading ? isCascadingLoading : isLoadingOptions;
// value가 변경되면 표시값 업데이트 (외래키 값으로 데이터 조회) // value가 변경되면 표시값 업데이트 (외래키 값으로 데이터 조회)
useEffect(() => { useEffect(() => {
const loadDisplayValue = async () => { const loadDisplayValue = async () => {
if (value && selectedData) { if (value && selectedData) {
// 이미 selectedData가 있으면 표시값만 업데이트 // 이미 selectedData가 있으면 표시값만 업데이트
setDisplayValue(selectedData[displayField] || ""); setDisplayValue(selectedData[displayField] || "");
} else if (value && mode === "select" && options.length > 0) { } else if (value && mode === "select" && effectiveOptions.length > 0) {
// select 모드에서 value가 있고 options가 로드된 경우 // select 모드에서 value가 있고 options가 로드된 경우
const found = options.find((opt) => opt[valueField] === value); const found = effectiveOptions.find((opt) => opt[valueField] === value);
if (found) { if (found) {
setSelectedData(found); setSelectedData(found);
setDisplayValue(found[displayField] || ""); setDisplayValue(found[displayField] || "");
@ -142,7 +229,7 @@ export function EntitySearchInputComponent({
}; };
loadDisplayValue(); loadDisplayValue();
}, [value, displayField, options, mode, valueField, tableName, selectedData]); }, [value, displayField, effectiveOptions, mode, valueField, tableName, selectedData]);
const handleSelect = (newValue: any, fullData: EntitySearchResult) => { const handleSelect = (newValue: any, fullData: EntitySearchResult) => {
setSelectedData(fullData); setSelectedData(fullData);
@ -200,7 +287,7 @@ export function EntitySearchInputComponent({
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={selectOpen} aria-expanded={selectOpen}
disabled={disabled || isLoadingOptions} disabled={disabled || isLoading || Boolean(shouldApplyCascading && !parentValue)}
className={cn( className={cn(
"w-full justify-between font-normal", "w-full justify-between font-normal",
!componentHeight && "h-8 text-xs sm:h-10 sm:text-sm", !componentHeight && "h-8 text-xs sm:h-10 sm:text-sm",
@ -208,7 +295,11 @@ export function EntitySearchInputComponent({
)} )}
style={inputStyle} style={inputStyle}
> >
{isLoadingOptions ? "로딩 중..." : displayValue || placeholder} {isLoading
? "로딩 중..."
: shouldApplyCascading && !parentValue
? "상위 항목을 먼저 선택하세요"
: displayValue || placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@ -218,7 +309,7 @@ export function EntitySearchInputComponent({
<CommandList> <CommandList>
<CommandEmpty className="py-4 text-center text-xs sm:text-sm"> .</CommandEmpty> <CommandEmpty className="py-4 text-center text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup> <CommandGroup>
{options.map((option, index) => ( {effectiveOptions.map((option, index) => (
<CommandItem <CommandItem
key={option[valueField] || index} key={option[valueField] || index}
value={`${option[displayField] || ""}-${option[valueField] || ""}`} value={`${option[displayField] || ""}-${option[valueField] || ""}`}

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useRef } from "react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -8,19 +8,27 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Plus, X, Check, ChevronsUpDown } from "lucide-react"; import { Plus, X, Check, ChevronsUpDown, Database, Info, Link2, ExternalLink } from "lucide-react";
// allComponents는 현재 사용되지 않지만 향후 확장을 위해 props에 유지
import { EntitySearchInputConfig } from "./config"; import { EntitySearchInputConfig } from "./config";
import { tableManagementApi } from "@/lib/api/tableManagement"; import { tableManagementApi } from "@/lib/api/tableManagement";
import { tableTypeApi } from "@/lib/api/screen";
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import Link from "next/link";
interface EntitySearchInputConfigPanelProps { interface EntitySearchInputConfigPanelProps {
config: EntitySearchInputConfig; config: EntitySearchInputConfig;
onConfigChange: (config: EntitySearchInputConfig) => void; onConfigChange: (config: EntitySearchInputConfig) => void;
currentComponent?: any; // 테이블 패널에서 드래그한 컴포넌트 정보
allComponents?: any[]; // 현재 화면의 모든 컴포넌트 (연쇄 드롭다운 부모 감지용)
} }
export function EntitySearchInputConfigPanel({ export function EntitySearchInputConfigPanel({
config, config,
onConfigChange, onConfigChange,
currentComponent,
allComponents = [],
}: EntitySearchInputConfigPanelProps) { }: EntitySearchInputConfigPanelProps) {
const [localConfig, setLocalConfig] = useState(config); const [localConfig, setLocalConfig] = useState(config);
const [allTables, setAllTables] = useState<any[]>([]); const [allTables, setAllTables] = useState<any[]>([]);
@ -31,7 +39,151 @@ export function EntitySearchInputConfigPanel({
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false); const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
const [openValueFieldCombo, setOpenValueFieldCombo] = useState(false); const [openValueFieldCombo, setOpenValueFieldCombo] = useState(false);
// 전체 테이블 목록 로드 // 연쇄 드롭다운 설정 상태 (SelectBasicConfigPanel과 동일)
const [cascadingEnabled, setCascadingEnabled] = useState(!!config.cascadingRelationCode);
// 연쇄관계 목록
const [relationList, setRelationList] = useState<CascadingRelation[]>([]);
const [loadingRelations, setLoadingRelations] = useState(false);
// 테이블 타입 관리에서 설정된 참조 테이블 정보
const [referenceInfo, setReferenceInfo] = useState<{
referenceTable: string;
referenceColumn: string;
displayColumn: string;
isLoading: boolean;
isAutoLoaded: boolean; // 자동 로드되었는지 여부
error: string | null;
}>({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: false,
isAutoLoaded: false,
error: null,
});
// 자동 설정 완료 여부 (중복 방지)
const autoConfigApplied = useRef(false);
// 테이블 패널에서 드래그한 컴포넌트인 경우, 참조 테이블 정보 자동 로드
useEffect(() => {
const loadReferenceInfo = async () => {
// currentComponent에서 소스 테이블/컬럼 정보 추출
const sourceTableName = currentComponent?.tableName || currentComponent?.sourceTableName;
const sourceColumnName = currentComponent?.columnName || currentComponent?.sourceColumnName;
if (!sourceTableName || !sourceColumnName) {
return;
}
// 이미 config에 테이블명이 설정되어 있고, 자동 로드가 완료되었다면 스킵
if (config.tableName && autoConfigApplied.current) {
return;
}
setReferenceInfo(prev => ({ ...prev, isLoading: true, error: null }));
try {
// 테이블 타입 관리에서 컬럼 정보 조회
const columns = await tableTypeApi.getColumns(sourceTableName);
const columnInfo = columns.find((col: any) =>
(col.columnName || col.column_name) === sourceColumnName
);
if (columnInfo) {
const refTable = columnInfo.referenceTable || columnInfo.reference_table || "";
const refColumn = columnInfo.referenceColumn || columnInfo.reference_column || "";
const dispColumn = columnInfo.displayColumn || columnInfo.display_column || "";
// detailSettings에서도 정보 확인 (JSON 파싱)
let detailSettings: any = {};
if (columnInfo.detailSettings) {
try {
if (typeof columnInfo.detailSettings === 'string') {
detailSettings = JSON.parse(columnInfo.detailSettings);
} else {
detailSettings = columnInfo.detailSettings;
}
} catch {
// JSON 파싱 실패 시 무시
}
}
const finalRefTable = refTable || detailSettings.referenceTable || "";
const finalRefColumn = refColumn || detailSettings.referenceColumn || "id";
const finalDispColumn = dispColumn || detailSettings.displayColumn || "name";
setReferenceInfo({
referenceTable: finalRefTable,
referenceColumn: finalRefColumn,
displayColumn: finalDispColumn,
isLoading: false,
isAutoLoaded: true,
error: null,
});
// 참조 테이블 정보로 config 자동 설정 (config에 아직 설정이 없는 경우만)
if (finalRefTable && !config.tableName) {
autoConfigApplied.current = true;
const newConfig: EntitySearchInputConfig = {
...localConfig,
tableName: finalRefTable,
valueField: finalRefColumn,
displayField: finalDispColumn,
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
}
} else {
setReferenceInfo({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: false,
isAutoLoaded: false,
error: "컬럼 정보를 찾을 수 없습니다.",
});
}
} catch (error) {
console.error("참조 테이블 정보 로드 실패:", error);
setReferenceInfo({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: false,
isAutoLoaded: false,
error: "참조 테이블 정보 로드 실패",
});
}
};
loadReferenceInfo();
}, [currentComponent?.tableName, currentComponent?.columnName, currentComponent?.sourceTableName, currentComponent?.sourceColumnName]);
// 연쇄 관계 목록 로드
useEffect(() => {
if (cascadingEnabled && relationList.length === 0) {
loadRelationList();
}
}, [cascadingEnabled]);
// 연쇄 관계 목록 로드 함수
const loadRelationList = async () => {
setLoadingRelations(true);
try {
const response = await cascadingRelationApi.getList("Y");
if (response.success && response.data) {
setRelationList(response.data);
}
} catch (error) {
console.error("연쇄 관계 목록 로드 실패:", error);
} finally {
setLoadingRelations(false);
}
};
// 전체 테이블 목록 로드 (수동 선택을 위해)
useEffect(() => { useEffect(() => {
const loadTables = async () => { const loadTables = async () => {
setIsLoadingTables(true); setIsLoadingTables(true);
@ -73,8 +225,11 @@ export function EntitySearchInputConfigPanel({
loadColumns(); loadColumns();
}, [localConfig.tableName]); }, [localConfig.tableName]);
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => { useEffect(() => {
setLocalConfig(config); setLocalConfig(config);
// 연쇄 드롭다운 설정 동기화
setCascadingEnabled(!!config.cascadingRelationCode);
}, [config]); }, [config]);
const updateConfig = (updates: Partial<EntitySearchInputConfig>) => { const updateConfig = (updates: Partial<EntitySearchInputConfig>) => {
@ -83,6 +238,71 @@ export function EntitySearchInputConfigPanel({
onConfigChange(newConfig); onConfigChange(newConfig);
}; };
// 연쇄 드롭다운 활성화/비활성화
const handleCascadingToggle = (enabled: boolean) => {
setCascadingEnabled(enabled);
if (!enabled) {
// 비활성화 시 관계 설정 제거
const newConfig = {
...localConfig,
cascadingRelationCode: undefined,
cascadingRole: undefined,
cascadingParentField: undefined,
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
} else {
// 활성화 시 관계 목록 로드
loadRelationList();
}
};
// 연쇄 관계 선택 (역할은 별도 선택)
const handleRelationSelect = (code: string) => {
const newConfig = {
...localConfig,
cascadingRelationCode: code || undefined,
cascadingRole: undefined, // 역할은 별도로 선택
cascadingParentField: undefined,
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
};
// 역할 변경 핸들러
const handleRoleChange = (role: "parent" | "child") => {
const selectedRel = relationList.find(r => r.relation_code === localConfig.cascadingRelationCode);
if (role === "parent" && selectedRel) {
// 부모 역할: 부모 테이블 정보로 설정
const newConfig = {
...localConfig,
cascadingRole: role,
tableName: selectedRel.parent_table,
valueField: selectedRel.parent_value_column,
displayField: selectedRel.parent_label_column || selectedRel.parent_value_column,
cascadingParentField: undefined, // 부모 역할이면 부모 필드 필요 없음
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
} else if (role === "child" && selectedRel) {
// 자식 역할: 자식 테이블 정보로 설정
const newConfig = {
...localConfig,
cascadingRole: role,
tableName: selectedRel.child_table,
valueField: selectedRel.child_value_column,
displayField: selectedRel.child_label_column || selectedRel.child_value_column,
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
}
};
// 선택된 관계 정보
const selectedRelation = relationList.find(r => r.relation_code === localConfig.cascadingRelationCode);
const addSearchField = () => { const addSearchField = () => {
const fields = localConfig.searchFields || []; const fields = localConfig.searchFields || [];
updateConfig({ searchFields: [...fields, ""] }); updateConfig({ searchFields: [...fields, ""] });
@ -134,10 +354,213 @@ export function EntitySearchInputConfigPanel({
updateConfig({ additionalFields: fields }); updateConfig({ additionalFields: fields });
}; };
// 자동 로드된 참조 테이블 정보가 있는지 확인
const hasAutoReference = referenceInfo.isAutoLoaded && referenceInfo.referenceTable;
return ( return (
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">
{/* 연쇄 드롭다운 설정 - SelectConfigPanel과 동일한 패턴 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4" />
<h4 className="text-sm font-medium"> </h4>
</div>
<Switch
checked={cascadingEnabled}
onCheckedChange={handleCascadingToggle}
/>
</div>
<p className="text-muted-foreground text-xs">
. (: 창고 )
</p>
{cascadingEnabled && (
<div className="space-y-3 rounded-md border p-3 bg-muted/30">
{/* 관계 선택 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={localConfig.cascadingRelationCode || ""}
onValueChange={handleRelationSelect}
>
<SelectTrigger className="text-xs">
<SelectValue placeholder={loadingRelations ? "로딩 중..." : "관계 선택"} />
</SelectTrigger>
<SelectContent>
{relationList.map((relation) => (
<SelectItem key={relation.relation_code} value={relation.relation_code}>
<div className="flex flex-col">
<span>{relation.relation_name}</span>
<span className="text-muted-foreground text-xs">
{relation.parent_table} {relation.child_table}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 역할 선택 */}
{localConfig.cascadingRelationCode && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={localConfig.cascadingRole === "parent" ? "default" : "outline"}
className="flex-1 text-xs"
onClick={() => handleRoleChange("parent")}
>
( )
</Button>
<Button
type="button"
size="sm"
variant={localConfig.cascadingRole === "child" ? "default" : "outline"}
className="flex-1 text-xs"
onClick={() => handleRoleChange("child")}
>
( )
</Button>
</div>
<p className="text-muted-foreground text-xs">
{localConfig.cascadingRole === "parent"
? "이 필드가 상위 선택 역할을 합니다. (예: 창고 선택)"
: localConfig.cascadingRole === "child"
? "이 필드는 상위 필드 값에 따라 옵션이 변경됩니다. (예: 위치 선택)"
: "이 필드의 역할을 선택하세요."}
</p>
</div>
)}
{/* 부모 필드 설정 (자식 역할일 때만) */}
{localConfig.cascadingRelationCode && localConfig.cascadingRole === "child" && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
value={localConfig.cascadingParentField || ""}
onChange={(e) => updateConfig({ cascadingParentField: e.target.value || undefined })}
placeholder="예: warehouse_code"
className="text-xs"
/>
<p className="text-muted-foreground text-xs">
.
</p>
</div>
)}
{/* 선택된 관계 정보 표시 */}
{selectedRelation && localConfig.cascadingRole && (
<div className="bg-background space-y-1 rounded-md p-2 text-xs">
{localConfig.cascadingRole === "parent" ? (
<>
<div className="font-medium text-blue-600"> ( )</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.parent_table}</span>
</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.parent_value_column}</span>
</div>
</>
) : (
<>
<div className="font-medium text-green-600"> ( )</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.child_table}</span>
</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.child_value_column}</span>
</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.child_filter_column}</span>
</div>
</>
)}
</div>
)}
{/* 관계 관리 페이지 링크 */}
<div className="flex justify-end">
<Link href="/admin/cascading-relations" target="_blank">
<Button variant="link" size="sm" className="h-auto p-0 text-xs">
<ExternalLink className="mr-1 h-3 w-3" />
</Button>
</Link>
</div>
</div>
)}
</div>
{/* 구분선 - 연쇄 드롭다운 비활성화 시에만 표시 */}
{!cascadingEnabled && (
<div className="border-t pt-4">
<p className="text-[10px] text-muted-foreground mb-4">
/ .
</p>
</div>
)}
{/* 참조 테이블 자동 로드 정보 표시 */}
{referenceInfo.isLoading && (
<div className="bg-muted/50 rounded-md border p-3">
<p className="text-xs text-muted-foreground"> ...</p>
</div>
)}
{hasAutoReference && !cascadingEnabled && (
<div className="bg-primary/5 rounded-md border border-primary/20 p-3 space-y-2">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-primary" />
<span className="text-xs font-medium text-primary"> </span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<span className="text-muted-foreground"> :</span>
<div className="font-medium">{referenceInfo.referenceTable}</div>
</div>
<div>
<span className="text-muted-foreground"> :</span>
<div className="font-medium">{referenceInfo.referenceColumn || "id"}</div>
</div>
<div>
<span className="text-muted-foreground"> :</span>
<div className="font-medium">{referenceInfo.displayColumn || "name"}</div>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
: {currentComponent?.tableName}.{currentComponent?.columnName}
</p>
</div>
)}
{referenceInfo.error && !hasAutoReference && !cascadingEnabled && (
<div className="bg-amber-500/10 rounded-md border border-amber-500/20 p-3">
<p className="text-xs text-amber-700 flex items-center gap-1">
<Info className="h-3 w-3" />
{referenceInfo.error}
</p>
<p className="text-[10px] text-muted-foreground mt-1">
.
</p>
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label> <Label className="text-xs sm:text-sm">
*
{hasAutoReference && (
<span className="text-[10px] text-muted-foreground ml-2">( )</span>
)}
</Label>
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}> <Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button

View File

@ -10,5 +10,10 @@ export interface EntitySearchInputConfig {
modalColumns?: string[]; modalColumns?: string[];
showAdditionalInfo?: boolean; showAdditionalInfo?: boolean;
additionalFields?: string[]; additionalFields?: string[];
// 연쇄관계 설정 (cascading_relation 테이블과 연동)
cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등)
cascadingRole?: "parent" | "child"; // 역할 (부모/자식)
cascadingParentField?: string; // 부모 필드의 컬럼명 (자식 역할일 때만 사용)
} }

View File

@ -23,6 +23,13 @@ export interface EntitySearchInputProps {
filterCondition?: Record<string, any>; // 추가 WHERE 조건 filterCondition?: Record<string, any>; // 추가 WHERE 조건
companyCode?: string; // 멀티테넌시 companyCode?: string; // 멀티테넌시
// 연쇄관계 설정
cascadingRelationCode?: string; // 연쇄관계 코드 (cascading_relation 테이블)
cascadingRole?: "parent" | "child"; // 역할 (부모/자식)
parentFieldId?: string; // 부모 필드의 컬럼명 (자식 역할일 때, formData에서 값 추출용)
parentValue?: any; // 부모 필드의 현재 값 (직접 전달)
formData?: Record<string, any>; // 전체 폼 데이터 (부모 값 추출용)
// 선택된 값 // 선택된 값
value?: any; value?: any;
onChange?: (value: any, fullData?: any) => void; onChange?: (value: any, fullData?: any) => void;

View File

@ -16,9 +16,16 @@ declare global {
masterData: Record<string, any> | null; masterData: Record<string, any> | null;
config: RelatedDataButtonsConfig | null; config: RelatedDataButtonsConfig | null;
}; };
// 🆕 RelatedDataButtons가 대상으로 하는 테이블 목록 (전역 레지스트리)
__relatedButtonsTargetTables?: Set<string>;
} }
} }
// 전역 레지스트리 초기화
if (typeof window !== "undefined" && !window.__relatedButtonsTargetTables) {
window.__relatedButtonsTargetTables = new Set();
}
interface RelatedDataButtonsComponentProps { interface RelatedDataButtonsComponentProps {
config: RelatedDataButtonsConfig; config: RelatedDataButtonsConfig;
className?: string; className?: string;
@ -59,11 +66,54 @@ export const RelatedDataButtonsComponent: React.FC<RelatedDataButtonsComponentPr
setMasterData(null); setMasterData(null);
setButtons([]); setButtons([]);
setSelectedId(null); setSelectedId(null);
setSelectedItem(null);
// 🆕 좌측 데이터가 없을 때 대상 테이블에 빈 상태 알림
if (config.events?.targetTable) {
window.dispatchEvent(new CustomEvent("related-button-select", {
detail: {
targetTable: config.events.targetTable,
filterColumn: config.events.targetFilterColumn,
filterValue: null, // null로 설정하여 빈 상태 표시
selectedData: null,
},
}));
}
return; return;
} }
setMasterData(splitPanelContext.selectedLeftData); setMasterData(splitPanelContext.selectedLeftData);
}, [splitPanelContext?.selectedLeftData]); }, [splitPanelContext?.selectedLeftData, config.events]);
// 🆕 컴포넌트 마운트 시 대상 테이블에 필터 필요 알림
useEffect(() => {
if (config.events?.targetTable) {
// 전역 레지스트리에 등록
window.__relatedButtonsTargetTables?.add(config.events.targetTable);
// 이벤트도 발생 (이미 마운트된 테이블 컴포넌트를 위해)
window.dispatchEvent(new CustomEvent("related-button-register", {
detail: {
targetTable: config.events.targetTable,
filterColumn: config.events.targetFilterColumn,
},
}));
console.log("📝 [RelatedDataButtons] 대상 테이블에 필터 등록:", config.events.targetTable);
}
return () => {
// 컴포넌트 언마운트 시 등록 해제
if (config.events?.targetTable) {
window.__relatedButtonsTargetTables?.delete(config.events.targetTable);
window.dispatchEvent(new CustomEvent("related-button-unregister", {
detail: {
targetTable: config.events.targetTable,
},
}));
}
};
}, [config.events?.targetTable, config.events?.targetFilterColumn]);
// 버튼 데이터 로드 // 버튼 데이터 로드
const loadButtons = useCallback(async () => { const loadButtons = useCallback(async () => {

View File

@ -9,6 +9,13 @@ import { codeCache } from "@/lib/caching/codeCache";
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization"; import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
import { getFullImageUrl } from "@/lib/api/client"; import { getFullImageUrl } from "@/lib/api/client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
// 🆕 RelatedDataButtons 전역 레지스트리 타입 선언
declare global {
interface Window {
__relatedButtonsTargetTables?: Set<string>;
}
}
import { import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
@ -201,6 +208,9 @@ export interface TableListComponentProps {
) => void; ) => void;
onConfigChange?: (config: any) => void; onConfigChange?: (config: any) => void;
refreshKey?: number; refreshKey?: number;
// 탭 관련 정보 (탭 내부의 테이블에서 사용)
parentTabId?: string; // 부모 탭 ID
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
} }
// ======================================== // ========================================
@ -217,7 +227,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
config, config,
className, className,
style, style,
formData: propFormData, // 🆕 부모에서 전달받은 formData formData: propFormData,
onFormDataChange, onFormDataChange,
componentConfig, componentConfig,
onSelectedRowsChange, onSelectedRowsChange,
@ -225,7 +235,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
refreshKey, refreshKey,
tableName, tableName,
userId, userId,
screenId, // 화면 ID 추출 screenId,
parentTabId,
parentTabsComponentId,
}) => { }) => {
// ======================================== // ========================================
// 설정 및 스타일 // 설정 및 스타일
@ -310,6 +322,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
filterValue: any; filterValue: any;
} | null>(null); } | null>(null);
// 🆕 RelatedDataButtons가 이 테이블을 대상으로 등록되어 있는지 여부
const [isRelatedButtonTarget, setIsRelatedButtonTarget] = useState(() => {
// 초기값: 전역 레지스트리에서 확인
if (typeof window !== "undefined" && window.__relatedButtonsTargetTables) {
return window.__relatedButtonsTargetTables.has(tableConfig.selectedTable || "");
}
return false;
});
// TableOptions Context // TableOptions Context
const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions(); const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions();
const [filters, setFilters] = useState<TableFilter[]>([]); const [filters, setFilters] = useState<TableFilter[]>([]);
@ -1000,7 +1021,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onGroupChange: setGrouping, onGroupChange: setGrouping,
onColumnVisibilityChange: setColumnVisibility, onColumnVisibilityChange: setColumnVisibility,
getColumnUniqueValues, // 고유 값 조회 함수 등록 getColumnUniqueValues, // 고유 값 조회 함수 등록
onGroupSumChange: setGroupSumConfig, // 🆕 그룹별 합산 설정 onGroupSumChange: setGroupSumConfig, // 그룹별 합산 설정
// 탭 관련 정보 (탭 내부의 테이블인 경우)
parentTabId,
parentTabsComponentId,
screenId: screenId ? Number(screenId) : undefined,
}; };
registerTable(registration); registerTable(registration);
@ -1554,6 +1579,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return; return;
} }
// 🆕 RelatedDataButtons 대상이지만 아직 버튼이 선택되지 않은 경우
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
if (isRelatedButtonTarget && !relatedButtonFilter) {
console.log("⚠️ [TableList] RelatedDataButtons 대상이지만 버튼 미선택 → 빈 데이터 표시");
setData([]);
setTotalItems(0);
setLoading(false);
return;
}
// 🆕 RelatedDataButtons 필터 값 준비 // 🆕 RelatedDataButtons 필터 값 준비
let relatedButtonFilterValues: Record<string, any> = {}; let relatedButtonFilterValues: Record<string, any> = {};
if (relatedButtonFilter) { if (relatedButtonFilter) {
@ -1767,6 +1802,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
splitPanelContext?.selectedLeftData, splitPanelContext?.selectedLeftData,
// 🆕 RelatedDataButtons 필터 추가 // 🆕 RelatedDataButtons 필터 추가
relatedButtonFilter, relatedButtonFilter,
isRelatedButtonTarget,
]); ]);
const fetchTableDataDebounced = useCallback( const fetchTableDataDebounced = useCallback(
@ -4783,6 +4819,45 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}; };
}, [tableConfig.selectedTable, isDesignMode]); }, [tableConfig.selectedTable, isDesignMode]);
// 🆕 테이블명 변경 시 전역 레지스트리에서 확인
useEffect(() => {
if (typeof window !== "undefined" && window.__relatedButtonsTargetTables && tableConfig.selectedTable) {
const isTarget = window.__relatedButtonsTargetTables.has(tableConfig.selectedTable);
if (isTarget) {
console.log("📝 [TableList] 전역 레지스트리에서 RelatedDataButtons 대상 확인:", tableConfig.selectedTable);
setIsRelatedButtonTarget(true);
}
}
}, [tableConfig.selectedTable]);
// 🆕 RelatedDataButtons 등록/해제 이벤트 리스너
useEffect(() => {
const handleRelatedButtonRegister = (event: CustomEvent) => {
const { targetTable } = event.detail || {};
if (targetTable === tableConfig.selectedTable) {
console.log("📝 [TableList] RelatedDataButtons 대상으로 등록됨:", tableConfig.selectedTable);
setIsRelatedButtonTarget(true);
}
};
const handleRelatedButtonUnregister = (event: CustomEvent) => {
const { targetTable } = event.detail || {};
if (targetTable === tableConfig.selectedTable) {
console.log("📝 [TableList] RelatedDataButtons 대상에서 해제됨:", tableConfig.selectedTable);
setIsRelatedButtonTarget(false);
setRelatedButtonFilter(null);
}
};
window.addEventListener("related-button-register" as any, handleRelatedButtonRegister);
window.addEventListener("related-button-unregister" as any, handleRelatedButtonUnregister);
return () => {
window.removeEventListener("related-button-register" as any, handleRelatedButtonRegister);
window.removeEventListener("related-button-unregister" as any, handleRelatedButtonUnregister);
};
}, [tableConfig.selectedTable]);
// 🆕 RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링) // 🆕 RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링)
useEffect(() => { useEffect(() => {
const handleRelatedButtonSelect = (event: CustomEvent) => { const handleRelatedButtonSelect = (event: CustomEvent) => {
@ -4790,12 +4865,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 이 테이블이 대상 테이블인지 확인 // 이 테이블이 대상 테이블인지 확인
if (targetTable === tableConfig.selectedTable) { if (targetTable === tableConfig.selectedTable) {
console.log("📌 [TableList] RelatedDataButtons 필터 적용:", { // filterValue가 null이면 선택 해제 (빈 상태)
tableName: tableConfig.selectedTable, if (filterValue === null || filterValue === undefined) {
filterColumn, console.log("📌 [TableList] RelatedDataButtons 선택 해제 (빈 상태):", tableConfig.selectedTable);
filterValue, setRelatedButtonFilter(null);
}); setIsRelatedButtonTarget(true); // 대상으로 등록은 유지
setRelatedButtonFilter({ filterColumn, filterValue }); } else {
console.log("📌 [TableList] RelatedDataButtons 필터 적용:", {
tableName: tableConfig.selectedTable,
filterColumn,
filterValue,
});
setRelatedButtonFilter({ filterColumn, filterValue });
setIsRelatedButtonTarget(true);
}
} }
}; };
@ -4808,8 +4891,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 relatedButtonFilter 변경 시 데이터 다시 로드 // 🆕 relatedButtonFilter 변경 시 데이터 다시 로드
useEffect(() => { useEffect(() => {
if (relatedButtonFilter && !isDesignMode) { if (!isDesignMode) {
console.log("🔄 [TableList] RelatedDataButtons 필터 변경으로 데이터 새로고침:", relatedButtonFilter); // relatedButtonFilter가 있으면 데이터 로드, null이면 빈 상태 (setRefreshTrigger로 트리거)
console.log("🔄 [TableList] RelatedDataButtons 상태 변경:", {
relatedButtonFilter,
isRelatedButtonTarget
});
setRefreshTrigger((prev) => prev + 1); setRefreshTrigger((prev) => prev + 1);
} }
}, [relatedButtonFilter, isDesignMode]); }, [relatedButtonFilter, isDesignMode]);

View File

@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input";
import { Settings, Filter, Layers, X, Check, ChevronsUpDown } from "lucide-react"; import { Settings, Filter, Layers, X, Check, ChevronsUpDown } from "lucide-react";
import { useTableOptions } from "@/contexts/TableOptionsContext"; import { useTableOptions } from "@/contexts/TableOptionsContext";
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
import { useActiveTab } from "@/contexts/ActiveTabContext";
import { ColumnVisibilityPanel } from "@/components/screen/table-options/ColumnVisibilityPanel"; import { ColumnVisibilityPanel } from "@/components/screen/table-options/ColumnVisibilityPanel";
import { FilterPanel } from "@/components/screen/table-options/FilterPanel"; import { FilterPanel } from "@/components/screen/table-options/FilterPanel";
import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel"; import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
@ -49,8 +50,9 @@ interface TableSearchWidgetProps {
} }
export function TableSearchWidget({ component, screenId, onHeightChange }: TableSearchWidgetProps) { export function TableSearchWidget({ component, screenId, onHeightChange }: TableSearchWidgetProps) {
const { registeredTables, selectedTableId, setSelectedTableId, getTable } = useTableOptions(); const { registeredTables, selectedTableId, setSelectedTableId, getTable, getActiveTabTables } = useTableOptions();
const { isPreviewMode } = useScreenPreview(); // 미리보기 모드 확인 const { isPreviewMode } = useScreenPreview(); // 미리보기 모드 확인
const { getAllActiveTabIds, activeTabs } = useActiveTab(); // 활성 탭 정보
// 높이 관리 context (실제 화면에서만 사용) // 높이 관리 context (실제 화면에서만 사용)
let setWidgetHeight: let setWidgetHeight:
@ -64,6 +66,9 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
setWidgetHeight = undefined; setWidgetHeight = undefined;
} }
// 탭별 필터 값 저장 (탭 ID -> 필터 값)
const [tabFilterValues, setTabFilterValues] = useState<Record<string, Record<string, any>>>({});
const [columnVisibilityOpen, setColumnVisibilityOpen] = useState(false); const [columnVisibilityOpen, setColumnVisibilityOpen] = useState(false);
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
const [groupingOpen, setGroupingOpen] = useState(false); const [groupingOpen, setGroupingOpen] = useState(false);
@ -88,38 +93,48 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// Map을 배열로 변환 // Map을 배열로 변환
const allTableList = Array.from(registeredTables.values()); const allTableList = Array.from(registeredTables.values());
// 대상 패널 위치에 따라 테이블 필터링 (tableId 패턴 기반) // 현재 활성 탭 ID 목록
const activeTabIds = useMemo(() => getAllActiveTabIds(), [activeTabs]);
// 대상 패널 위치 + 활성 탭에 따라 테이블 필터링
const tableList = useMemo(() => { const tableList = useMemo(() => {
// "auto"면 모든 테이블 반환 // 1단계: 활성 탭 기반 필터링
if (targetPanelPosition === "auto") { // - 활성 탭에 속한 테이블만 표시
return allTableList; // - 탭에 속하지 않은 테이블(parentTabId가 없는)도 포함
} let filteredByTab = allTableList.filter(table => {
// 탭에 속하지 않는 테이블은 항상 표시
// 테이블 ID 패턴으로 필터링 if (!table.parentTabId) return true;
// card-display-XXX: 좌측 패널 (카드 디스플레이) // 활성 탭에 속한 테이블만 표시
// datatable-XXX, table-list-XXX: 우측 패널 (테이블 리스트) return activeTabIds.includes(table.parentTabId);
const filteredTables = allTableList.filter(table => {
const tableId = table.tableId.toLowerCase();
if (targetPanelPosition === "left") {
// 좌측 패널 대상: card-display만
return tableId.includes("card-display") || tableId.includes("card");
} else if (targetPanelPosition === "right") {
// 우측 패널 대상: datatable, table-list 등 (card-display 제외)
const isCardDisplay = tableId.includes("card-display") || tableId.includes("card");
return !isCardDisplay;
}
return true;
}); });
// 필터링된 결과가 없으면 모든 테이블 반환 (폴백) // 2단계: 대상 패널 위치에 따라 추가 필터링
if (filteredTables.length === 0) { if (targetPanelPosition !== "auto") {
return allTableList; filteredByTab = filteredByTab.filter(table => {
const tableId = table.tableId.toLowerCase();
if (targetPanelPosition === "left") {
// 좌측 패널 대상: card-display만
return tableId.includes("card-display") || tableId.includes("card");
} else if (targetPanelPosition === "right") {
// 우측 패널 대상: datatable, table-list 등 (card-display 제외)
const isCardDisplay = tableId.includes("card-display") || tableId.includes("card");
return !isCardDisplay;
}
return true;
});
} }
return filteredTables; // 필터링된 결과가 없으면 탭 기반 필터링 결과만 반환
}, [allTableList, targetPanelPosition]); if (filteredByTab.length === 0) {
return allTableList.filter(table =>
!table.parentTabId || activeTabIds.includes(table.parentTabId)
);
}
return filteredByTab;
}, [allTableList, targetPanelPosition, activeTabIds]);
// currentTable은 tableList(필터링된 목록)에서 가져와야 함 // currentTable은 tableList(필터링된 목록)에서 가져와야 함
const currentTable = useMemo(() => { const currentTable = useMemo(() => {
@ -151,6 +166,34 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
} }
}, [tableList, selectedTableId, autoSelectFirstTable, setSelectedTableId, targetPanelPosition]); }, [tableList, selectedTableId, autoSelectFirstTable, setSelectedTableId, targetPanelPosition]);
// 현재 선택된 테이블의 탭 ID (탭별 필터 저장용)
const currentTableTabId = currentTable?.parentTabId;
// 탭별 필터 값 저장 키 생성
const getTabFilterStorageKey = (tableName: string, tabId?: string) => {
const baseKey = screenId
? `table_filter_values_${tableName}_screen_${screenId}`
: `table_filter_values_${tableName}`;
return tabId ? `${baseKey}_tab_${tabId}` : baseKey;
};
// 탭 변경 시 이전 탭의 필터 값 저장 + 새 탭의 필터 값 복원
useEffect(() => {
if (!currentTable?.tableName) return;
// 현재 필터 값이 있으면 탭별로 저장
if (Object.keys(filterValues).length > 0 && currentTableTabId) {
const storageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
localStorage.setItem(storageKey, JSON.stringify(filterValues));
// 메모리 캐시에도 저장
setTabFilterValues(prev => ({
...prev,
[currentTableTabId]: filterValues
}));
}
}, [currentTableTabId, currentTable?.tableName]);
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드) // 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
useEffect(() => { useEffect(() => {
if (!currentTable?.tableName) return; if (!currentTable?.tableName) return;
@ -165,14 +208,32 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
width: f.width || 200, width: f.width || 200,
})); }));
setActiveFilters(activeFiltersList); setActiveFilters(activeFiltersList);
// 탭별 저장된 필터 값 복원
if (currentTableTabId) {
const storageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
const savedValues = localStorage.getItem(storageKey);
if (savedValues) {
try {
const parsedValues = JSON.parse(savedValues);
setFilterValues(parsedValues);
// 즉시 필터 적용
setTimeout(() => applyFilters(parsedValues), 100);
} catch {
setFilterValues({});
}
} else {
setFilterValues({});
}
}
return; return;
} }
// 동적 모드: 화면별로 독립적인 필터 설정 불러오기 // 동적 모드: 화면별 + 탭별로 독립적인 필터 설정 불러오기
const storageKey = screenId const filterConfigKey = screenId
? `table_filters_${currentTable.tableName}_screen_${screenId}` ? `table_filters_${currentTable.tableName}_screen_${screenId}${currentTableTabId ? `_tab_${currentTableTabId}` : ''}`
: `table_filters_${currentTable.tableName}`; : `table_filters_${currentTable.tableName}`;
const savedFilters = localStorage.getItem(storageKey); const savedFilters = localStorage.getItem(filterConfigKey);
if (savedFilters) { if (savedFilters) {
try { try {
@ -193,16 +254,39 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
operator: "contains", operator: "contains",
value: "", value: "",
filterType: f.filterType, filterType: f.filterType,
width: f.width || 200, // 저장된 너비 포함 width: f.width || 200,
})); }));
setActiveFilters(activeFiltersList); setActiveFilters(activeFiltersList);
// 탭별 저장된 필터 값 복원
if (currentTableTabId) {
const valuesStorageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
const savedValues = localStorage.getItem(valuesStorageKey);
if (savedValues) {
try {
const parsedValues = JSON.parse(savedValues);
setFilterValues(parsedValues);
// 즉시 필터 적용
setTimeout(() => applyFilters(parsedValues), 100);
} catch {
setFilterValues({});
}
} else {
setFilterValues({});
}
} else {
setFilterValues({});
}
} catch (error) { } catch (error) {
console.error("저장된 필터 불러오기 실패:", error); console.error("저장된 필터 불러오기 실패:", error);
} }
} else {
// 필터 설정이 없으면 초기화
setFilterValues({});
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTable?.tableName, filterMode, screenId, JSON.stringify(presetFilters)]); }, [currentTable?.tableName, filterMode, screenId, currentTableTabId, JSON.stringify(presetFilters)]);
// select 옵션 초기 로드 (한 번만 실행, 이후 유지) // select 옵션 초기 로드 (한 번만 실행, 이후 유지)
useEffect(() => { useEffect(() => {
@ -300,6 +384,12 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
setFilterValues(newValues); setFilterValues(newValues);
// 탭별 필터 값 저장
if (currentTable?.tableName && currentTableTabId) {
const storageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
localStorage.setItem(storageKey, JSON.stringify(newValues));
}
// 실시간 검색: 값 변경 시 즉시 필터 적용 // 실시간 검색: 값 변경 시 즉시 필터 적용
applyFilters(newValues); applyFilters(newValues);
}; };
@ -365,6 +455,12 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
setFilterValues({}); setFilterValues({});
setSelectedLabels({}); setSelectedLabels({});
currentTable?.onFilterChange([]); currentTable?.onFilterChange([]);
// 탭별 저장된 필터 값도 초기화
if (currentTable?.tableName && currentTableTabId) {
const storageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
localStorage.removeItem(storageKey);
}
}; };
// 필터 입력 필드 렌더링 // 필터 입력 필드 렌더링

View File

@ -397,7 +397,6 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
// 🆕 수주 등록 관련 컴포넌트들은 간단한 인터페이스 사용 // 🆕 수주 등록 관련 컴포넌트들은 간단한 인터페이스 사용
const isSimpleConfigPanel = [ const isSimpleConfigPanel = [
"autocomplete-search-input", "autocomplete-search-input",
"entity-search-input",
"modal-repeater-table", "modal-repeater-table",
"conditional-container", "conditional-container",
].includes(componentId); ].includes(componentId);
@ -406,6 +405,19 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
return <ConfigPanelComponent config={config} onConfigChange={onChange} />; return <ConfigPanelComponent config={config} onConfigChange={onChange} />;
} }
// entity-search-input은 currentComponent 정보 필요 (참조 테이블 자동 로드용)
// 그리고 allComponents 필요 (연쇄관계 부모 필드 선택용)
if (componentId === "entity-search-input") {
return (
<ConfigPanelComponent
config={config}
onConfigChange={onChange}
currentComponent={currentComponent}
allComponents={allComponents}
/>
);
}
// 🆕 selected-items-detail-input은 특별한 props 사용 // 🆕 selected-items-detail-input은 특별한 props 사용
if (componentId === "selected-items-detail-input") { if (componentId === "selected-items-detail-input") {
return ( return (

View File

@ -56,11 +56,16 @@ export interface TableRegistration {
columns: TableColumn[]; columns: TableColumn[];
dataCount?: number; // 현재 표시된 데이터 건수 dataCount?: number; // 현재 표시된 데이터 건수
// 탭 관련 정보 (탭 내부에 있는 테이블의 경우)
parentTabId?: string; // 부모 탭 ID
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
screenId?: number; // 소속 화면 ID
// 콜백 함수들 // 콜백 함수들
onFilterChange: (filters: TableFilter[]) => void; onFilterChange: (filters: TableFilter[]) => void;
onGroupChange: (groups: string[]) => void; onGroupChange: (groups: string[]) => void;
onColumnVisibilityChange: (columns: ColumnVisibility[]) => void; onColumnVisibilityChange: (columns: ColumnVisibility[]) => void;
onGroupSumChange?: (config: GroupSumConfig | null) => void; // 🆕 그룹별 합산 설정 변경 onGroupSumChange?: (config: GroupSumConfig | null) => void; // 그룹별 합산 설정 변경
// 데이터 조회 함수 (선택 타입 필터용) // 데이터 조회 함수 (선택 타입 필터용)
getColumnUniqueValues?: (columnName: string) => Promise<Array<{ label: string; value: string }>>; getColumnUniqueValues?: (columnName: string) => Promise<Array<{ label: string; value: string }>>;
@ -77,4 +82,8 @@ export interface TableOptionsContextValue {
updateTableDataCount: (tableId: string, count: number) => void; // 데이터 건수 업데이트 updateTableDataCount: (tableId: string, count: number) => void; // 데이터 건수 업데이트
selectedTableId: string | null; selectedTableId: string | null;
setSelectedTableId: (tableId: string | null) => void; setSelectedTableId: (tableId: string | null) => void;
// 활성 탭 기반 필터링
getActiveTabTables: () => TableRegistration[]; // 현재 활성 탭의 테이블만 반환
getTablesForTab: (tabId: string) => TableRegistration[]; // 특정 탭의 테이블만 반환
} }