Compare commits

...

16 Commits

Author SHA1 Message Date
kjs 176a2c2c37 Merge pull request 'jskim-node' (#419) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/419
2026-03-17 09:56:32 +09:00
kjs c35e217001 Merge branch 'main' into jskim-node 2026-03-17 09:56:24 +09:00
kjs 4e65af4919 Merge branch 'gbpark-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-17 09:55:51 +09:00
kjs 1d1f04f854 Merge branch 'mhkim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-17 09:55:33 +09:00
DDD1542 4ba931dc70 2026-03-17 09:54:44 +09:00
kjs e8dc1a287a Merge branch 'mhkim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-17 09:46:07 +09:00
kmh cf4296b020 feat: implement pagination settings in split panel layout
- Added pagination configuration options for both left and right panels in the SplitPanelLayoutComponent, allowing for server-side data retrieval in pages.
- Introduced a new PaginationConfig interface to manage pagination settings, including page size.
- Enhanced data loading functions to support pagination, improving data management and user experience.

Made-with: Cursor
2026-03-17 09:44:41 +09:00
DDD1542 6a50e1e924 feat: add primary key and index toggle functionality to ColumnGrid component
- Introduced `onPkToggle` and `onIndexToggle` props to the `ColumnGrid` component, allowing users to toggle primary key and index states directly from the UI.
- Updated the `TableManagementPage` to handle these new toggle events, enhancing the interactivity and usability of the table management features.

These changes aim to improve the table configuration process within the ERP system, providing users with more control over their table structures.
2026-03-16 18:59:45 +09:00
kjs 8da48bfe9c feat: enhance V2TableListConfigPanel with editable column locking feature
- Added a button to toggle the editable state of columns in the V2TableListConfigPanel, allowing users to lock or unlock editing for specific columns.
- Implemented visual indicators (lock/unlock icons) to represent the editable state of each column, improving user interaction and clarity.
- Enhanced the button's tooltip to provide context on the current state (editable or locked) when hovered.

These updates aim to improve the usability of the table configuration panel by providing users with more control over column editing capabilities.

Made-with: Cursor
2026-03-16 18:45:43 +09:00
DDD1542 43aafb36c1 feat: enhance table management page with improved filtering and UI updates
- Implemented Korean prioritization in table filtering, allowing for better sorting of table names based on Korean characters.
- Updated the UI to a more compact design with a top bar for better accessibility and user experience.
- Added new button styles and functionalities for creating and duplicating tables, enhancing the overall management capabilities.
- Improved the column detail panel with clearer labeling and enhanced interaction for selecting data types and reference tables.

These changes aim to streamline the table management process and improve usability within the ERP system.
2026-03-16 17:58:37 +09:00
DDD1542 a391918e58 [agent-pipeline] pipe-20260316081628-53mz round-1 2026-03-16 17:28:34 +09:00
DDD1542 825f164bde 22 2026-03-16 17:15:12 +09:00
kjs b4a5fb9aa3 feat: enhance ButtonConfigPanel and V2ButtonConfigPanel with improved data handling
- Updated the ButtonConfigPanel to fetch a larger set of screens by modifying the API call to include a size parameter.
- Enhanced the V2ButtonConfigPanel with new state variables and effects for managing data transfer field mappings, including loading available tables and their columns.
- Implemented multi-table mapping logic to support complex data transfer actions, improving the flexibility and usability of the component.
- Added a dedicated section for field mapping in the UI, allowing users to configure data transfer settings more effectively.

These updates aim to enhance the functionality and user experience of the button configuration panels within the ERP system, enabling better data management and transfer capabilities.

Made-with: Cursor
2026-03-16 16:47:33 +09:00
DDD1542 7bb74ec449 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node
Made-with: Cursor

; Conflicts:
;	backend-node/src/services/numberingRuleService.ts
2026-03-16 16:39:02 +09:00
DDD1542 dfd26e1933 11 2026-03-16 16:28:42 +09:00
DDD1542 e305e78155 Implement Comma Value Resolution in Entity Join Service
- Added a new method `resolveCommaValues` in `EntityJoinService` to handle comma-separated values for entity joins, allowing for individual code resolution and label conversion.
- Integrated the new method into `TableManagementService` to process data after executing join queries.
- Enhanced the `DynamicComponentRenderer` to maintain entity label columns based on existing configurations.

Made-with: Cursor
2026-03-16 11:43:26 +09:00
25 changed files with 3458 additions and 1571 deletions

View File

@ -823,6 +823,76 @@ export class EntityJoinService {
return [];
}
}
/**
* ( )
* entity join이 NULL인데
*/
async resolveCommaValues(
data: Record<string, any>[],
joinConfigs: EntityJoinConfig[]
): Promise<Record<string, any>[]> {
if (!data.length || !joinConfigs.length) return data;
for (const config of joinConfigs) {
const sourceCol = config.sourceColumn;
const displayCol = config.displayColumns?.[0] || config.displayColumn;
if (!displayCol || displayCol === "none") continue;
const aliasCol = config.aliasColumn || `${sourceCol}_${displayCol}`;
const labelCol = `${sourceCol}_label`;
const codesSet = new Set<string>();
const rowsToResolve: number[] = [];
data.forEach((row, idx) => {
const srcVal = row[sourceCol];
if (!srcVal || typeof srcVal !== "string" || !srcVal.includes(",")) return;
const joinedVal = row[aliasCol] || row[labelCol];
if (joinedVal && joinedVal !== "") return;
rowsToResolve.push(idx);
srcVal.split(",").map((v: string) => v.trim()).filter(Boolean).forEach((code: string) => codesSet.add(code));
});
if (codesSet.size === 0) continue;
const codes = Array.from(codesSet);
const refCol = config.referenceColumn || "id";
const placeholders = codes.map((_, i) => `$${i + 1}`).join(",");
try {
const result = await query<Record<string, any>>(
`SELECT "${refCol}"::TEXT as _key, "${displayCol}"::TEXT as _label
FROM ${config.referenceTable}
WHERE "${refCol}"::TEXT IN (${placeholders})`,
codes
);
const labelMap = new Map<string, string>();
result.forEach((r) => labelMap.set(r._key, r._label));
for (const idx of rowsToResolve) {
const srcVal = data[idx][sourceCol] as string;
const resolvedLabels = srcVal
.split(",")
.map((v: string) => v.trim())
.filter(Boolean)
.map((code: string) => labelMap.get(code) || code)
.join(", ");
data[idx][aliasCol] = resolvedLabels;
data[idx][labelCol] = resolvedLabels;
}
logger.info(`콤마 구분 entity 값 해결: ${sourceCol}${codesSet.size}개 코드, ${rowsToResolve.length}개 행`);
} catch (err) {
logger.warn(`콤마 구분 entity 값 해결 실패: ${sourceCol}`, err);
}
}
return data;
}
}
export const entityJoinService = new EntityJoinService();

View File

@ -235,6 +235,312 @@ class NumberingRuleService {
);
return result.rows[0].current_sequence;
}
/**
* (GREATEST )
*
*/
private async setSequenceForPrefix(
client: any,
ruleId: string,
companyCode: string,
prefixKey: string,
targetSequence: number
): Promise<number> {
const result = await client.query(
`INSERT INTO numbering_rule_sequences (rule_id, company_code, prefix_key, current_sequence, last_allocated_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (rule_id, company_code, prefix_key)
DO UPDATE SET current_sequence = GREATEST(numbering_rule_sequences.current_sequence, $4),
last_allocated_at = NOW()
RETURNING current_sequence`,
[ruleId, companyCode, prefixKey, targetSequence]
);
return result.rows[0].current_sequence;
}
/**
* 퀀
* prefix/suffix sequence MAX
*/
private async getMaxSequenceFromTable(
client: any,
tableName: string,
columnName: string,
codePrefix: string,
codeSuffix: string,
seqLength: number,
companyCode: string
): Promise<number> {
try {
// 테이블에 company_code 컬럼이 있는지 확인
const colCheck = await client.query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
const hasCompanyCode = colCheck.rows.length > 0;
// 대상 컬럼 존재 여부 확인
const targetColCheck = await client.query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = $2`,
[tableName, columnName]
);
if (targetColCheck.rows.length === 0) {
logger.warn(`getMaxSequenceFromTable: 컬럼 없음 ${tableName}.${columnName}`);
return 0;
}
// prefix와 suffix 사이의 sequence 부분을 추출하기 위한 위치 계산
const prefixLen = codePrefix.length;
const seqStart = prefixLen + 1; // SQL SUBSTRING은 1-based
// LIKE 패턴: prefix + N자리 숫자 + suffix
const likePattern = codePrefix + "%" + codeSuffix;
let sql: string;
let params: any[];
if (hasCompanyCode && companyCode !== "*") {
sql = `
SELECT MAX(
CAST(SUBSTRING("${columnName}" FROM $1 FOR $2) AS INTEGER)
) as max_seq
FROM "${tableName}"
WHERE "${columnName}" LIKE $3
AND company_code = $4
AND LENGTH("${columnName}") = $5
AND SUBSTRING("${columnName}" FROM $1 FOR $2) ~ '^[0-9]+$'
`;
params = [seqStart, seqLength, likePattern, companyCode, prefixLen + seqLength + codeSuffix.length];
} else {
sql = `
SELECT MAX(
CAST(SUBSTRING("${columnName}" FROM $1 FOR $2) AS INTEGER)
) as max_seq
FROM "${tableName}"
WHERE "${columnName}" LIKE $3
AND LENGTH("${columnName}") = $4
AND SUBSTRING("${columnName}" FROM $1 FOR $2) ~ '^[0-9]+$'
`;
params = [seqStart, seqLength, likePattern, prefixLen + seqLength + codeSuffix.length];
}
const result = await client.query(sql, params);
const maxSeq = result.rows[0]?.max_seq ?? 0;
logger.info("getMaxSequenceFromTable 결과", {
tableName, columnName, codePrefix, codeSuffix,
seqLength, companyCode, maxSeq,
});
return maxSeq;
} catch (error: any) {
logger.warn("getMaxSequenceFromTable 실패 (카운터 폴백)", {
tableName, columnName, error: error.message,
});
return 0;
}
}
/**
* sequence prefix/suffix를
* allocateCode/previewCode에서 -sequence
*/
private buildCodePrefixSuffix(
partValues: string[],
sortedParts: any[],
globalSeparator: string
): { prefix: string; suffix: string; seqIndex: number; seqLength: number } | null {
const seqIndex = sortedParts.findIndex((p: any) => p.partType === "sequence");
if (seqIndex === -1) return null;
const seqLength = sortedParts[seqIndex].autoConfig?.sequenceLength || 3;
// prefix: sequence 파트 이전의 모든 파트값 + 구분자
let prefix = "";
for (let i = 0; i < seqIndex; i++) {
prefix += partValues[i];
const sep = sortedParts[i].separatorAfter ?? sortedParts[i].autoConfig?.separatorAfter ?? globalSeparator;
prefix += sep;
}
// suffix: sequence 파트 이후의 모든 파트값 + 구분자
let suffix = "";
for (let i = seqIndex + 1; i < partValues.length; i++) {
const sep = sortedParts[i - 1].separatorAfter ?? sortedParts[i - 1].autoConfig?.separatorAfter ?? globalSeparator;
if (i === seqIndex + 1) {
// sequence 파트 바로 뒤 구분자
const seqSep = sortedParts[seqIndex].separatorAfter ?? sortedParts[seqIndex].autoConfig?.separatorAfter ?? globalSeparator;
suffix += seqSep;
}
suffix += partValues[i];
if (i < partValues.length - 1) {
const nextSep = sortedParts[i].separatorAfter ?? sortedParts[i].autoConfig?.separatorAfter ?? globalSeparator;
suffix += nextSep;
}
}
return { prefix, suffix, seqIndex, seqLength };
}
/**
* -sequence prefix/suffix
* sequence ( buildCodePrefixSuffix에서 )
*/
private async computeNonSequenceValues(
rule: NumberingRuleConfig,
formData?: Record<string, any>
): Promise<string[]> {
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
return Promise.all(sortedParts.map(async (part: any) => {
if (part.partType === "sequence") return "";
if (part.generationMethod === "manual") return "";
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "text":
return autoConfig.textValue || "TEXT";
case "number": {
const length = autoConfig.numberLength || 3;
const value = autoConfig.numberValue || 1;
return String(value).padStart(length, "0");
}
case "date": {
const dateFormat = autoConfig.dateFormat || "YYYYMMDD";
if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) {
const columnValue = formData[autoConfig.sourceColumnName];
if (columnValue) {
const dateValue = columnValue instanceof Date ? columnValue : new Date(columnValue);
if (!isNaN(dateValue.getTime())) {
return this.formatDate(dateValue, dateFormat);
}
}
}
return this.formatDate(new Date(), dateFormat);
}
case "category": {
const categoryKey = autoConfig.categoryKey;
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) return "";
const colName = categoryKey.includes(".") ? categoryKey.split(".")[1] : categoryKey;
const selectedValue = formData[colName];
if (!selectedValue) return "";
const selectedValueStr = String(selectedValue);
let mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === selectedValueStr) return true;
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
if (!mapping) {
try {
const pool = getPool();
const [ct, cc] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey];
const cvResult = await pool.query(
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[ct, cc, selectedValueStr]
);
if (cvResult.rows.length > 0) {
mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === String(cvResult.rows[0].value_id)) return true;
if (m.categoryValueLabel === cvResult.rows[0].value_label) return true;
return false;
});
}
} catch { /* ignore */ }
}
return mapping?.format || "";
}
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
return "";
}
default:
return "";
}
}));
}
/**
* 퀀 ,
* + 1
*/
private async resolveNextSequence(
client: any,
rule: NumberingRuleConfig,
companyCode: string,
ruleId: string,
prefixKey: string,
formData?: Record<string, any>
): Promise<number> {
// 1. 현재 저장된 카운터 조회
const currentCounter = await this.getSequenceForPrefix(
client, ruleId, companyCode, prefixKey
);
let baseSequence = currentCounter;
// 2. 규칙에 tableName/columnName이 설정되어 있으면 대상 테이블에서 MAX 조회
if (rule.tableName && rule.columnName) {
try {
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const patternValues = await this.computeNonSequenceValues(rule, formData);
const psInfo = this.buildCodePrefixSuffix(patternValues, sortedParts, rule.separator || "");
if (psInfo) {
const maxFromTable = await this.getMaxSequenceFromTable(
client, rule.tableName, rule.columnName,
psInfo.prefix, psInfo.suffix, psInfo.seqLength, companyCode
);
if (maxFromTable > baseSequence) {
logger.info("테이블 내 최대값이 카운터보다 높음 → 동기화", {
ruleId, companyCode, currentCounter, maxFromTable,
});
baseSequence = maxFromTable;
}
}
} catch (error: any) {
logger.warn("테이블 기반 MAX 조회 실패, 카운터 기반 폴백", {
ruleId, error: error.message,
});
}
}
// 3. 다음 시퀀스 = base + 1
const nextSequence = baseSequence + 1;
// 4. 카운터를 동기화 (GREATEST 사용)
await this.setSequenceForPrefix(client, ruleId, companyCode, prefixKey, nextSequence);
// 5. 호환성을 위해 numbering_rules.current_sequence도 업데이트
await client.query(
"UPDATE numbering_rules SET current_sequence = GREATEST(COALESCE(current_sequence, 0), $3) WHERE rule_id = $1 AND company_code = $2",
[ruleId, companyCode, nextSequence]
);
logger.info("resolveNextSequence 완료", {
ruleId, companyCode, prefixKey, currentCounter, baseSequence, nextSequence,
});
return nextSequence;
}
/**
* ()
*/
@ -1101,6 +1407,7 @@ class NumberingRuleService {
const hasManualPart = rule.parts.some((p: any) => p.generationMethod === "manual");
const skipSequenceLookup = hasManualPart && !manualInputValue;
// prefix_key 기반 순번 조회 + 테이블 내 최대값과 비교
const manualValues = manualInputValue ? [manualInputValue] : undefined;
const prefixKey = await this.buildPrefixKey(rule, formData, manualValues);
const pool = getPool();
@ -1108,8 +1415,36 @@ class NumberingRuleService {
? 0
: await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
logger.info("미리보기: prefix_key 기반 순번 조회", {
ruleId, prefixKey, currentSeq, skipSequenceLookup,
// 대상 테이블에서 실제 최대 시퀀스 조회
let baseSeq = currentSeq;
if (rule.tableName && rule.columnName) {
try {
const sortedPartsForPattern = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const patternValues = await this.computeNonSequenceValues(rule, formData);
const psInfo = this.buildCodePrefixSuffix(patternValues, sortedPartsForPattern, rule.separator || "");
if (psInfo) {
const maxFromTable = await this.getMaxSequenceFromTable(
pool, rule.tableName, rule.columnName,
psInfo.prefix, psInfo.suffix, psInfo.seqLength, companyCode
);
if (maxFromTable > baseSeq) {
logger.info("미리보기: 테이블 내 최대값이 카운터보다 높음", {
ruleId, companyCode, currentSeq, maxFromTable,
});
baseSeq = maxFromTable;
}
}
} catch (error: any) {
logger.warn("미리보기: 테이블 기반 MAX 조회 실패, 카운터 기반 폴백", {
ruleId, error: error.message,
});
}
}
logger.info("미리보기: 순번 조회 완료", {
ruleId, prefixKey, currentSeq, baseSeq, skipSequenceLookup,
});
const parts = await Promise.all(rule.parts
@ -1125,7 +1460,7 @@ class NumberingRuleService {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
const startFrom = autoConfig.startFrom || 1;
const nextSequence = currentSeq + startFrom;
const nextSequence = baseSeq + startFrom;
return String(nextSequence).padStart(length, "0");
}
@ -1239,20 +1574,15 @@ class NumberingRuleService {
const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues);
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
// 3단계: 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득
// 3단계: 순번이 있으면 prefix_key 기반 UPSERT + 테이블 내 최대값 비교하여 다음 순번 결정
let allocatedSequence = 0;
if (hasSequence) {
allocatedSequence = await this.incrementSequenceForPrefix(
client, ruleId, companyCode, prefixKey
);
// 호환성을 위해 기존 current_sequence도 업데이트
await client.query(
"UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2",
[ruleId, companyCode]
allocatedSequence = await this.resolveNextSequence(
client, rule, companyCode, ruleId, prefixKey, formData
);
}
logger.info("allocateCode: prefix_key 기반 순번 할당", {
logger.info("allocateCode: prefix_key + 테이블 기반 순번 할당", {
ruleId, prefixKey, allocatedSequence, extractedManualValues,
});

View File

@ -3588,12 +3588,15 @@ export class TableManagementService {
`✅ [executeJoinQuery] 조회 완료: ${dataResult?.length}개 행`
);
const data = Array.isArray(dataResult) ? dataResult : [];
let data = Array.isArray(dataResult) ? dataResult : [];
const total =
Array.isArray(countResult) && countResult.length > 0
? Number((countResult[0] as any).total)
: 0;
// 콤마 구분 다중값 후처리 (겸직 부서 등)
data = await entityJoinService.resolveCommaValues(data, joinConfigs);
const queryTime = Date.now() - startTime;
return {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,498 @@
"use client";
import React, { useMemo } from "react";
import { X, Type, Settings2, Tag, ToggleLeft, ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Badge } from "@/components/ui/badge";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ColumnTypeInfo, TableInfo, SecondLevelMenu } from "./types";
import { INPUT_TYPE_COLORS } from "./types";
import type { ReferenceTableColumn } from "@/lib/api/entityJoin";
import type { NumberingRuleConfig } from "@/types/numbering-rule";
export interface ColumnDetailPanelProps {
column: ColumnTypeInfo | null;
tables: TableInfo[];
referenceTableColumns: Record<string, ReferenceTableColumn[]>;
secondLevelMenus: SecondLevelMenu[];
numberingRules: NumberingRuleConfig[];
onColumnChange: (field: keyof ColumnTypeInfo, value: unknown) => void;
onClose: () => void;
onLoadReferenceColumns?: (tableName: string) => void;
/** 코드 카테고리 옵션 (value, label) */
codeCategoryOptions?: Array<{ value: string; label: string }>;
/** 참조 테이블 옵션 (value, label) */
referenceTableOptions?: Array<{ value: string; label: string }>;
}
export function ColumnDetailPanel({
column,
tables,
referenceTableColumns,
numberingRules,
onColumnChange,
onClose,
onLoadReferenceColumns,
codeCategoryOptions = [],
referenceTableOptions = [],
}: ColumnDetailPanelProps) {
const [advancedOpen, setAdvancedOpen] = React.useState(false);
const [entityTableOpen, setEntityTableOpen] = React.useState(false);
const [entityColumnOpen, setEntityColumnOpen] = React.useState(false);
const [numberingOpen, setNumberingOpen] = React.useState(false);
const typeConf = column ? INPUT_TYPE_COLORS[column.inputType || "text"] : null;
const refColumns = column?.referenceTable
? referenceTableColumns[column.referenceTable] ?? []
: [];
React.useEffect(() => {
if (column?.referenceTable && column.referenceTable !== "none") {
onLoadReferenceColumns?.(column.referenceTable);
}
}, [column?.referenceTable, onLoadReferenceColumns]);
const advancedCount = useMemo(() => {
if (!column) return 0;
let n = 0;
if (column.defaultValue != null && column.defaultValue !== "") n++;
if (column.maxLength != null && column.maxLength > 0) n++;
return n;
}, [column]);
if (!column) return null;
const refTableOpts = referenceTableOptions.length
? referenceTableOptions
: [{ value: "none", label: "선택 안함" }, ...tables.map((t) => ({ value: t.tableName, label: t.displayName || t.tableName }))];
return (
<div className="flex h-full w-full flex-col border-l bg-card">
{/* 헤더 */}
<div className="flex flex-shrink-0 items-center justify-between border-b px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
{typeConf && (
<span className={cn("rounded px-2 py-0.5 text-xs", typeConf.bgColor, typeConf.color)}>
{typeConf.label}
</span>
)}
<span className="truncate font-mono text-sm font-medium">{column.columnName}</span>
</div>
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={onClose} aria-label="닫기">
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{/* [섹션 1] 데이터 타입 선택 */}
<section className="space-y-2">
<div>
<p className="text-sm font-semibold"> ?</p>
<p className="text-xs text-muted-foreground"> </p>
</div>
<div className="grid grid-cols-3 gap-1.5">
{Object.entries(INPUT_TYPE_COLORS).map(([type, conf]) => {
const isSelected = (column.inputType || "text") === type;
return (
<button
key={type}
type="button"
onClick={() => onColumnChange("inputType", type)}
className={cn(
"flex flex-col items-center gap-1 rounded-lg border px-1.5 py-2.5 text-center transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-primary/30 hover:bg-accent/50",
)}
>
<span className={cn(
"text-base font-bold leading-none",
isSelected ? "text-primary" : conf.color,
)}>
{conf.iconChar}
</span>
<span className={cn(
"text-[11px] font-semibold leading-tight",
isSelected ? "text-primary" : "text-foreground",
)}>
{conf.label}
</span>
<span className="text-[9px] leading-tight text-muted-foreground">
{conf.desc}
</span>
</button>
);
})}
</div>
</section>
{/* [섹션 2] 타입별 상세 설정 */}
{column.inputType === "entity" && (
<section className="space-y-3">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium"> </Label>
</div>
{/* 참조 테이블 */}
<div className="space-y-1">
<Label className="text-[11px] font-medium text-muted-foreground"> </Label>
<Popover open={entityTableOpen} onOpenChange={setEntityTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-9 w-full justify-between text-xs"
>
{column.referenceTable && column.referenceTable !== "none"
? refTableOpts.find((o) => o.value === column.referenceTable)?.label ?? column.referenceTable
: "테이블 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
{refTableOpts.map((opt) => (
<CommandItem
key={opt.value}
value={`${opt.label} ${opt.value}`}
onSelect={() => {
onColumnChange("referenceTable", opt.value === "none" ? undefined : opt.value);
if (opt.value !== "none") onLoadReferenceColumns?.(opt.value);
setEntityTableOpen(false);
}}
className="text-xs"
>
<Check
className={cn("mr-2 h-3 w-3", column.referenceTable === opt.value ? "opacity-100" : "opacity-0")}
/>
{opt.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 조인 컬럼 */}
{column.referenceTable && column.referenceTable !== "none" && (
<div className="space-y-1">
<Label className="text-[11px] font-medium text-muted-foreground"> ()</Label>
<Popover open={entityColumnOpen} onOpenChange={setEntityColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={refColumns.length === 0}
className="h-9 w-full justify-between text-xs"
>
{column.referenceColumn && column.referenceColumn !== "none"
? column.referenceColumn
: "컬럼 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
<CommandItem
value="none"
onSelect={() => {
onColumnChange("referenceColumn", undefined);
setEntityColumnOpen(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", !column.referenceColumn ? "opacity-100" : "opacity-0")} />
</CommandItem>
{refColumns.map((refCol) => (
<CommandItem
key={refCol.columnName}
value={`${refCol.displayName ?? ""} ${refCol.columnName}`}
onSelect={() => {
onColumnChange("referenceColumn", refCol.columnName);
setEntityColumnOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
column.referenceColumn === refCol.columnName ? "opacity-100" : "opacity-0",
)}
/>
{refCol.columnName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* 참조 요약 미니맵 */}
{column.referenceTable && column.referenceTable !== "none" && column.referenceColumn && (
<div className="flex items-center gap-2 rounded-md bg-violet-50 px-3 py-2">
<span className="font-mono text-[11px] font-semibold text-violet-600">
{column.referenceTable}
</span>
<span className="text-muted-foreground text-[10px]"></span>
<span className="font-mono text-[11px] font-semibold text-violet-600">
{column.referenceColumn}
</span>
</div>
)}
</section>
)}
{column.inputType === "code" && (
<section className="space-y-2">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium"> </Label>
</div>
<div className="space-y-2">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"> </Label>
<Select
value={column.codeCategory ?? "none"}
onValueChange={(v) => onColumnChange("codeCategory", v === "none" ? undefined : v)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="코드 선택" />
</SelectTrigger>
<SelectContent>
{[{ value: "none", label: "선택 안함" }, ...codeCategoryOptions].map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{column.codeCategory && column.codeCategory !== "none" && (
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"> </Label>
<Select
value={column.hierarchyRole ?? "none"}
onValueChange={(v) =>
onColumnChange("hierarchyRole", v === "none" ? undefined : (v as "large" | "medium" | "small"))
}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="일반" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="large"></SelectItem>
<SelectItem value="medium"></SelectItem>
<SelectItem value="small"></SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
</section>
)}
{column.inputType === "category" && (
<section className="space-y-2">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium"> </Label>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"> (.)</Label>
<Input
value={column.categoryRef ?? ""}
onChange={(e) => onColumnChange("categoryRef", e.target.value || null)}
placeholder="테이블명.컬럼명"
className="h-9 text-xs"
/>
</div>
</section>
)}
{column.inputType === "numbering" && (
<section className="space-y-2">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium"> </Label>
</div>
<Popover open={numberingOpen} onOpenChange={setNumberingOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="h-9 w-full justify-between text-xs">
{column.numberingRuleId
? numberingRules.find((r) => r.ruleId === column.numberingRuleId)?.ruleName ?? column.numberingRuleId
: "규칙 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="규칙 검색..." className="h-8 text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
<CommandItem
value="none"
onSelect={() => {
onColumnChange("numberingRuleId", undefined);
setNumberingOpen(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", !column.numberingRuleId ? "opacity-100" : "opacity-0")} />
</CommandItem>
{numberingRules.map((r) => (
<CommandItem
key={r.ruleId}
value={`${r.ruleName} ${r.ruleId}`}
onSelect={() => {
onColumnChange("numberingRuleId", r.ruleId);
setNumberingOpen(false);
}}
className="text-xs"
>
<Check
className={cn("mr-2 h-3 w-3", column.numberingRuleId === r.ruleId ? "opacity-100" : "opacity-0")}
/>
{r.ruleName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</section>
)}
{/* [섹션 3] 표시 이름 */}
<section className="space-y-2">
<div className="flex items-center gap-2">
<Tag className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium"> </Label>
</div>
<Input
value={column.displayName ?? ""}
onChange={(e) => onColumnChange("displayName", e.target.value)}
placeholder={column.columnName}
className="h-9 text-sm"
/>
</section>
{/* [섹션 4] 표시 옵션 */}
<section className="space-y-2">
<div className="flex items-center gap-2">
<ToggleLeft className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium"> </Label>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium"> </p>
<p className="text-xs text-muted-foreground"> .</p>
</div>
<Switch
checked={column.isNullable === "NO"}
onCheckedChange={(checked) => onColumnChange("isNullable", checked ? "NO" : "YES")}
aria-label="필수 입력"
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium"> </p>
<p className="text-xs text-muted-foreground"> .</p>
</div>
<Switch
checked={false}
onCheckedChange={() => {}}
disabled
aria-label="읽기 전용 (향후 확장)"
/>
</div>
</div>
</section>
{/* [섹션 5] 고급 설정 */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between py-1 text-left"
aria-expanded={advancedOpen}
>
<div className="flex items-center gap-2">
{advancedOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-sm font-medium"> </span>
{advancedCount > 0 && (
<Badge variant="secondary" className="text-xs">
{advancedCount}
</Badge>
)}
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-3 pt-2">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input
value={column.defaultValue ?? ""}
onChange={(e) => onColumnChange("defaultValue", e.target.value)}
placeholder="기본값"
className="h-9 text-xs"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"> </Label>
<Input
type="number"
value={column.maxLength ?? ""}
onChange={(e) => {
const v = e.target.value;
onColumnChange("maxLength", v === "" ? undefined : Number(v));
}}
placeholder="숫자"
className="h-9 text-xs"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
);
}

View File

@ -0,0 +1,281 @@
"use client";
import React, { useMemo } from "react";
import { MoreHorizontal, Database, Layers, FileStack } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { ColumnTypeInfo } from "./types";
import { INPUT_TYPE_COLORS, getColumnGroup } from "./types";
export interface ColumnGridConstraints {
primaryKey: { columns: string[] };
indexes: Array<{ columns: string[]; isUnique: boolean }>;
}
export interface ColumnGridProps {
columns: ColumnTypeInfo[];
selectedColumn: string | null;
onSelectColumn: (columnName: string) => void;
onColumnChange: (columnName: string, field: keyof ColumnTypeInfo, value: unknown) => void;
constraints: ColumnGridConstraints;
typeFilter?: string | null;
getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean };
onPkToggle?: (columnName: string, checked: boolean) => void;
onIndexToggle?: (columnName: string, checked: boolean) => void;
}
function getIndexState(
columnName: string,
constraints: ColumnGridConstraints,
): { isPk: boolean; hasIndex: boolean } {
const isPk = constraints.primaryKey.columns.includes(columnName);
const hasIndex = constraints.indexes.some(
(idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
);
return { isPk, hasIndex };
}
/** 그룹 헤더 라벨 */
const GROUP_LABELS: Record<string, { icon: React.ElementType; label: string }> = {
basic: { icon: FileStack, label: "기본 정보" },
reference: { icon: Layers, label: "참조 정보" },
meta: { icon: Database, label: "메타 정보" },
};
export function ColumnGrid({
columns,
selectedColumn,
onSelectColumn,
onColumnChange,
constraints,
typeFilter = null,
getColumnIndexState: externalGetIndexState,
onPkToggle,
onIndexToggle,
}: ColumnGridProps) {
const getIdxState = useMemo(
() => externalGetIndexState ?? ((name: string) => getIndexState(name, constraints)),
[constraints, externalGetIndexState],
);
/** typeFilter 적용 후 그룹별로 정렬 */
const filteredAndGrouped = useMemo(() => {
const filtered =
typeFilter != null ? columns.filter((c) => (c.inputType || "text") === typeFilter) : columns;
const groups = { basic: [] as ColumnTypeInfo[], reference: [] as ColumnTypeInfo[], meta: [] as ColumnTypeInfo[] };
for (const col of filtered) {
const group = getColumnGroup(col);
groups[group].push(col);
}
return groups;
}, [columns, typeFilter]);
const totalFiltered =
filteredAndGrouped.basic.length + filteredAndGrouped.reference.length + filteredAndGrouped.meta.length;
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div
className="grid flex-shrink-0 items-center border-b bg-muted/50 px-4 py-2 text-xs font-semibold text-foreground"
style={{ gridTemplateColumns: "4px 140px 1fr 100px 160px 40px" }}
>
<span />
<span> · </span>
<span>/</span>
<span></span>
<span className="text-center">PK / NN / IDX / UQ</span>
<span />
</div>
<div className="flex-1 overflow-y-auto">
{totalFiltered === 0 ? (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
{typeFilter ? "해당 타입의 컬럼이 없습니다." : "컬럼이 없습니다."}
</div>
) : (
(["basic", "reference", "meta"] as const).map((groupKey) => {
const list = filteredAndGrouped[groupKey];
if (list.length === 0) return null;
const { icon: Icon, label } = GROUP_LABELS[groupKey];
return (
<div key={groupKey} className="space-y-1 py-2">
<div className="flex items-center gap-2 border-b border-border/60 px-4 pb-1.5">
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</span>
<Badge variant="secondary" className="text-xs">
{list.length}
</Badge>
</div>
{list.map((column) => {
const typeConf = INPUT_TYPE_COLORS[column.inputType || "text"] || INPUT_TYPE_COLORS.text;
const idxState = getIdxState(column.columnName);
const isSelected = selectedColumn === column.columnName;
return (
<div
key={column.columnName}
role="button"
tabIndex={0}
onClick={() => onSelectColumn(column.columnName)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelectColumn(column.columnName);
}
}}
className={cn(
"grid min-h-12 cursor-pointer items-center gap-2 rounded-md border px-4 py-2 transition-colors",
"grid-cols-[4px_140px_1fr_100px_160px_40px]",
"bg-card border-transparent hover:border-border hover:shadow-sm",
isSelected && "border-primary/30 bg-primary/5 shadow-sm",
)}
>
{/* 4px 색상바 (타입별 진한 색) */}
<div className={cn("h-full min-h-8 w-1 rounded-full", typeConf.barColor)} />
{/* 라벨 + 컬럼명 */}
<div className="min-w-0">
<div className="truncate text-sm font-medium">
{column.displayName || column.columnName}
</div>
<div className="truncate font-mono text-xs text-muted-foreground">
{column.columnName}
</div>
</div>
{/* 참조/설정 칩 */}
<div className="flex min-w-0 flex-wrap gap-1">
{column.inputType === "entity" && column.referenceTable && column.referenceTable !== "none" && (
<>
<Badge variant="outline" className="text-xs font-normal">
{column.referenceTable}
</Badge>
<span className="text-muted-foreground text-xs"></span>
<Badge variant="outline" className="text-xs font-normal">
{column.referenceColumn || "—"}
</Badge>
</>
)}
{column.inputType === "code" && (
<span className="text-muted-foreground truncate text-xs">
{column.codeCategory ?? "—"} · {column.defaultValue ?? ""}
</span>
)}
{column.inputType === "numbering" && column.numberingRuleId && (
<Badge variant="outline" className="text-xs font-normal">
{column.numberingRuleId}
</Badge>
)}
{column.inputType !== "entity" &&
column.inputType !== "code" &&
column.inputType !== "numbering" &&
(column.defaultValue ? (
<span className="text-muted-foreground truncate text-xs">{column.defaultValue}</span>
) : (
<span className="text-muted-foreground/60 text-xs"></span>
))}
</div>
{/* 타입 뱃지 */}
<div className={cn("rounded-md border px-2 py-0.5 text-xs", typeConf.bgColor, typeConf.color)}>
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-current opacity-70" />
{typeConf.label}
</div>
{/* PK / NN / IDX / UQ (클릭 토글) */}
<div className="flex flex-wrap items-center justify-center gap-1">
<button
type="button"
className={cn(
"rounded border px-1.5 py-0.5 text-[10px] font-bold transition-colors",
idxState.isPk
? "border-blue-200 bg-blue-50 text-blue-600"
: "border-border text-muted-foreground/40 hover:border-blue-200 hover:text-blue-400",
)}
onClick={(e) => {
e.stopPropagation();
onPkToggle?.(column.columnName, !idxState.isPk);
}}
title="Primary Key 토글"
>
PK
</button>
<button
type="button"
className={cn(
"rounded border px-1.5 py-0.5 text-[10px] font-bold transition-colors",
column.isNullable === "NO"
? "border-amber-200 bg-amber-50 text-amber-600"
: "border-border text-muted-foreground/40 hover:border-amber-200 hover:text-amber-400",
)}
onClick={(e) => {
e.stopPropagation();
onColumnChange(column.columnName, "isNullable", column.isNullable === "NO" ? "YES" : "NO");
}}
title="Not Null 토글"
>
NN
</button>
<button
type="button"
className={cn(
"rounded border px-1.5 py-0.5 text-[10px] font-bold transition-colors",
idxState.hasIndex
? "border-emerald-200 bg-emerald-50 text-emerald-600"
: "border-border text-muted-foreground/40 hover:border-emerald-200 hover:text-emerald-400",
)}
onClick={(e) => {
e.stopPropagation();
onIndexToggle?.(column.columnName, !idxState.hasIndex);
}}
title="Index 토글"
>
IDX
</button>
<button
type="button"
className={cn(
"rounded border px-1.5 py-0.5 text-[10px] font-bold transition-colors",
column.isUnique === "YES"
? "border-violet-200 bg-violet-50 text-violet-600"
: "border-border text-muted-foreground/40 hover:border-violet-200 hover:text-violet-400",
)}
onClick={(e) => {
e.stopPropagation();
onColumnChange(column.columnName, "isUnique", column.isUnique === "YES" ? "NO" : "YES");
}}
title="Unique 토글"
>
UQ
</button>
</div>
<div className="flex items-center justify-center">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onSelectColumn(column.columnName);
}}
aria-label="상세 설정"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
</div>
);
})
)}
</div>
</div>
);
}

View File

@ -0,0 +1,114 @@
"use client";
import React, { useMemo } from "react";
import { cn } from "@/lib/utils";
import type { ColumnTypeInfo } from "./types";
import { INPUT_TYPE_COLORS } from "./types";
export interface TypeOverviewStripProps {
columns: ColumnTypeInfo[];
activeFilter?: string | null;
onFilterChange?: (type: string | null) => void;
}
/** inputType별 카운트 계산 */
function countByInputType(columns: ColumnTypeInfo[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const col of columns) {
const t = col.inputType || "text";
counts[t] = (counts[t] || 0) + 1;
}
return counts;
}
/** 도넛 차트용 비율 (0~1) 배열 및 라벨 순서 */
function getDonutSegments(counts: Record<string, number>, total: number): Array<{ type: string; ratio: number }> {
const order = Object.keys(INPUT_TYPE_COLORS);
return order
.filter((type) => (counts[type] || 0) > 0)
.map((type) => ({ type, ratio: (counts[type] || 0) / total }));
}
export function TypeOverviewStrip({
columns,
activeFilter = null,
onFilterChange,
}: TypeOverviewStripProps) {
const { counts, total, segments } = useMemo(() => {
const counts = countByInputType(columns);
const total = columns.length || 1;
const segments = getDonutSegments(counts, total);
return { counts, total, segments };
}, [columns]);
/** stroke-dasharray: 비율만큼 둘레에 할당 (둘레 100 기준) */
const circumference = 100;
let offset = 0;
const segmentPaths = segments.map(({ type, ratio }) => {
const length = ratio * circumference;
const dashArray = `${length} ${circumference - length}`;
const dashOffset = -offset;
offset += length;
const conf = INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted" };
return {
type,
dashArray,
dashOffset,
...conf,
};
});
return (
<div className="flex flex-shrink-0 items-center gap-3 border-b bg-muted/30 px-5 py-2.5">
{/* SVG 도넛 (원형 stroke) */}
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center">
<svg className="h-10 w-10 -rotate-90" viewBox="0 0 36 36">
{segmentPaths.map((seg) => (
<g key={seg.type} className={cn(seg.color, "opacity-80")}>
<circle
cx="18"
cy="18"
r="14"
fill="none"
stroke="currentColor"
strokeWidth="6"
strokeDasharray={seg.dashArray}
strokeDashoffset={seg.dashOffset}
aria-hidden
/>
</g>
))}
{segments.length === 0 && (
<circle cx="18" cy="18" r="14" fill="none" stroke="currentColor" strokeWidth="6" className="text-muted-foreground/50" />
)}
</svg>
</div>
{/* 타입 칩 목록 (클릭 시 필터 토글) */}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
{Object.entries(counts)
.sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))
.map(([type]) => {
const conf = INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted", label: type };
const isActive = activeFilter === null || activeFilter === type;
return (
<button
key={type}
type="button"
onClick={() => onFilterChange?.(activeFilter === type ? null : type)}
className={cn(
"rounded-md border px-2 py-1 text-xs font-medium transition-colors",
conf.bgColor,
conf.color,
"border-current/20",
isActive ? "ring-1 ring-ring" : "opacity-70 hover:opacity-100",
)}
>
{conf.label} {counts[type]}
</button>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,77 @@
/**
*
* page.tsx에서 /
*/
export interface TableInfo {
tableName: string;
displayName: string;
description: string;
columnCount: number;
}
export interface ColumnTypeInfo {
columnName: string;
displayName: string;
inputType: string;
detailSettings: string;
description: string;
isNullable: string;
isUnique: string;
defaultValue?: string;
maxLength?: number;
numericPrecision?: number;
numericScale?: number;
codeCategory?: string;
codeValue?: string;
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string;
categoryMenus?: number[];
hierarchyRole?: "large" | "medium" | "small";
numberingRuleId?: string;
categoryRef?: string | null;
}
export interface SecondLevelMenu {
menuObjid: number;
menuName: string;
parentMenuName: string;
screenCode?: string;
}
/** 컬럼 그룹 분류 */
export type ColumnGroup = "basic" | "reference" | "meta";
/** 타입별 색상 매핑 (다크모드 호환 레이어 사용) */
export interface TypeColorConfig {
color: string;
bgColor: string;
barColor: string;
label: string;
desc: string;
iconChar: string;
}
/** 입력 타입별 색상 맵 - iconChar는 카드 선택용 시각 아이콘 */
export const INPUT_TYPE_COLORS: Record<string, TypeColorConfig> = {
text: { color: "text-slate-600", bgColor: "bg-slate-50", barColor: "bg-slate-400", label: "텍스트", desc: "일반 텍스트 입력", iconChar: "T" },
number: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-500", label: "숫자", desc: "숫자만 입력", iconChar: "#" },
date: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "날짜", desc: "날짜 선택", iconChar: "D" },
code: { color: "text-emerald-600", bgColor: "bg-emerald-50", barColor: "bg-emerald-500", label: "코드", desc: "공통코드 선택", iconChar: "{}" },
entity: { color: "text-violet-600", bgColor: "bg-violet-50", barColor: "bg-violet-500", label: "테이블 참조", desc: "다른 테이블 연결", iconChar: "⊞" },
select: { color: "text-cyan-600", bgColor: "bg-cyan-50", barColor: "bg-cyan-500", label: "셀렉트", desc: "직접 옵션 선택", iconChar: "☰" },
checkbox: { color: "text-pink-600", bgColor: "bg-pink-50", barColor: "bg-pink-500", label: "체크박스", desc: "예/아니오 선택", iconChar: "☑" },
numbering: { color: "text-orange-600", bgColor: "bg-orange-50", barColor: "bg-orange-500", label: "채번", desc: "자동 번호 생성", iconChar: "≡" },
category: { color: "text-teal-600", bgColor: "bg-teal-50", barColor: "bg-teal-500", label: "카테고리", desc: "등록된 선택지", iconChar: "⊟" },
textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-400", label: "여러 줄", desc: "긴 텍스트 입력", iconChar: "≡" },
radio: { color: "text-rose-600", bgColor: "bg-rose-50", barColor: "bg-rose-500", label: "라디오", desc: "하나만 선택", iconChar: "◉" },
};
/** 컬럼 그룹 판별 */
export function getColumnGroup(col: ColumnTypeInfo): ColumnGroup {
const metaCols = ["id", "created_date", "updated_date", "writer", "company_code"];
if (metaCols.includes(col.columnName)) return "meta";
if (["entity", "code", "category"].includes(col.inputType)) return "reference";
return "basic";
}

View File

@ -1338,7 +1338,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
size: {
...splitAdjustedComponent.size,
width: undefined as unknown as number,
height: undefined as unknown as number,
},
} : {}),
}

View File

@ -577,7 +577,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
// - 버튼 컴포넌트: buttonElementStyle에서 자체 border 적용
const isV2HorizLabel = !!(
componentStyle &&
(componentStyle.labelDisplay === true || componentStyle.labelDisplay === "true") &&
componentStyle.labelDisplay !== false && componentStyle.labelDisplay !== "false" &&
(componentStyle.labelPosition === "left" || componentStyle.labelPosition === "right")
);
const needsStripBorder = isV2HorizLabel || isButtonComponent;

View File

@ -2,8 +2,6 @@
import React, { useRef, useState, useEffect } from "react";
import { ComponentData } from "@/types/screen";
import { useResponsive } from "@/lib/hooks/useResponsive";
import { cn } from "@/lib/utils";
interface ResponsiveGridRendererProps {
components: ComponentData[];
@ -12,60 +10,6 @@ interface ResponsiveGridRendererProps {
renderComponent: (component: ComponentData) => React.ReactNode;
}
const FULL_WIDTH_TYPES = new Set([
"table-list",
"v2-table-list",
"table-search-widget",
"v2-table-search-widget",
"conditional-container",
"split-panel-layout",
"split-panel-layout2",
"v2-split-panel-layout",
"screen-split-panel",
"v2-split-line",
"flow-widget",
"v2-tab-container",
"tab-container",
"tabs-widget",
"v2-tabs-widget",
]);
const FLEX_GROW_TYPES = new Set([
"table-list",
"v2-table-list",
"split-panel-layout",
"split-panel-layout2",
"v2-split-panel-layout",
"screen-split-panel",
"v2-tab-container",
"tab-container",
"tabs-widget",
"v2-tabs-widget",
]);
function groupComponentsIntoRows(
components: ComponentData[],
threshold: number = 30
): ComponentData[][] {
if (components.length === 0) return [];
const sorted = [...components].sort((a, b) => a.position.y - b.position.y);
const rows: ComponentData[][] = [];
let currentRow: ComponentData[] = [];
let currentRowY = -Infinity;
for (const comp of sorted) {
if (comp.position.y - currentRowY > threshold) {
if (currentRow.length > 0) rows.push(currentRow);
currentRow = [comp];
currentRowY = comp.position.y;
} else {
currentRow.push(comp);
}
}
if (currentRow.length > 0) rows.push(currentRow);
return rows.map((row) => row.sort((a, b) => a.position.x - b.position.x));
}
function getComponentTypeId(component: ComponentData): string {
const direct =
(component as any).componentType || (component as any).widgetType;
@ -78,132 +22,10 @@ function getComponentTypeId(component: ComponentData): string {
return component.type || "";
}
function isButtonComponent(component: ComponentData): boolean {
return getComponentTypeId(component).includes("button");
}
function isFullWidthComponent(component: ComponentData): boolean {
return FULL_WIDTH_TYPES.has(getComponentTypeId(component));
}
function shouldFlexGrow(component: ComponentData): boolean {
return FLEX_GROW_TYPES.has(getComponentTypeId(component));
}
function getPercentageWidth(componentWidth: number, canvasWidth: number): number {
const pct = (componentWidth / canvasWidth) * 100;
return pct >= 95 ? 100 : pct;
}
function getRowGap(row: ComponentData[], canvasWidth: number): number {
if (row.length < 2) return 0;
const totalW = row.reduce((s, c) => s + (c.size?.width || 100), 0);
const gap = canvasWidth - totalW;
const cnt = row.length - 1;
if (gap <= 0 || cnt <= 0) return 8;
return Math.min(Math.max(Math.round(gap / cnt), 4), 24);
}
interface ProcessedRow {
type: "normal" | "fullwidth";
mainComponent?: ComponentData;
overlayComps: ComponentData[];
normalComps: ComponentData[];
rowMinY?: number;
rowMaxBottom?: number;
}
function FullWidthOverlayRow({
main,
overlayComps,
canvasWidth,
renderComponent,
}: {
main: ComponentData;
overlayComps: ComponentData[];
canvasWidth: number;
renderComponent: (component: ComponentData) => React.ReactNode;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerW, setContainerW] = useState(0);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width;
if (w && w > 0) setContainerW(w);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const compFlexGrow = shouldFlexGrow(main);
const mainY = main.position.y;
const scale = containerW > 0 ? containerW / canvasWidth : 1;
const minButtonY = Math.min(...overlayComps.map((c) => c.position.y));
const rawYOffset = minButtonY - mainY;
const maxBtnH = Math.max(
...overlayComps.map((c) => c.size?.height || 40)
);
const yOffset = rawYOffset + (maxBtnH / 2) * (1 - scale);
return (
<div
ref={containerRef}
className={cn(
"relative flex w-full flex-col",
compFlexGrow ? "min-h-0 flex-1" : "flex-shrink-0"
)}
>
<div
data-component-id={main.id}
data-component-type={getComponentTypeId(main)}
className="min-h-0 min-w-0"
style={{
width: "100%",
height: compFlexGrow ? "100%" : "auto",
minHeight: compFlexGrow ? "300px" : undefined,
flexGrow: 1,
}}
>
{renderComponent(main)}
</div>
{overlayComps.length > 0 && containerW > 0 && (
<div
className="pointer-events-none absolute left-0 z-10"
style={{
top: `${yOffset}px`,
width: `${canvasWidth}px`,
height: `${maxBtnH}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
}}
>
{overlayComps.map((comp) => (
<div
key={comp.id}
data-component-id={comp.id}
data-component-type={getComponentTypeId(comp)}
className="pointer-events-auto absolute"
style={{
left: `${comp.position.x}px`,
top: `${comp.position.y - minButtonY}px`,
width: `${comp.size?.width || 90}px`,
height: `${comp.size?.height || 40}px`,
}}
>
{renderComponent(comp)}
</div>
))}
</div>
)}
</div>
);
}
/**
* .
* , .
*/
function ProportionalRenderer({
components,
canvasWidth,
@ -270,220 +92,13 @@ export function ResponsiveGridRenderer({
canvasHeight,
renderComponent,
}: ResponsiveGridRendererProps) {
const { isMobile } = useResponsive();
const topLevel = components.filter((c) => !c.parentId);
const hasFullWidthComponent = topLevel.some((c) => isFullWidthComponent(c));
if (!isMobile && !hasFullWidthComponent) {
return (
<ProportionalRenderer
components={components}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
renderComponent={renderComponent}
/>
);
}
const rows = groupComponentsIntoRows(topLevel);
const processedRows: ProcessedRow[] = [];
for (const row of rows) {
const fullWidthComps: ComponentData[] = [];
const normalComps: ComponentData[] = [];
for (const comp of row) {
if (isFullWidthComponent(comp)) {
fullWidthComps.push(comp);
} else {
normalComps.push(comp);
}
}
const allComps = [...fullWidthComps, ...normalComps];
const rowMinY = allComps.length > 0 ? Math.min(...allComps.map(c => c.position.y)) : 0;
const rowMaxBottom = allComps.length > 0 ? Math.max(...allComps.map(c => c.position.y + (c.size?.height || 40))) : 0;
if (fullWidthComps.length > 0 && normalComps.length > 0) {
for (const fwComp of fullWidthComps) {
processedRows.push({
type: "fullwidth",
mainComponent: fwComp,
overlayComps: normalComps,
normalComps: [],
rowMinY,
rowMaxBottom,
});
}
} else if (fullWidthComps.length > 0) {
for (const fwComp of fullWidthComps) {
processedRows.push({
type: "fullwidth",
mainComponent: fwComp,
overlayComps: [],
normalComps: [],
rowMinY,
rowMaxBottom,
});
}
} else {
processedRows.push({
type: "normal",
overlayComps: [],
normalComps,
rowMinY,
rowMaxBottom,
});
}
}
return (
<div
data-screen-runtime="true"
className="bg-background flex h-full w-full flex-col overflow-x-hidden"
style={{ minHeight: "200px" }}
>
{processedRows.map((processedRow, rowIndex) => {
const rowMarginTop = (() => {
if (rowIndex === 0) return 0;
const prevRow = processedRows[rowIndex - 1];
const prevBottom = prevRow.rowMaxBottom ?? 0;
const currTop = processedRow.rowMinY ?? 0;
const designGap = currTop - prevBottom;
if (designGap <= 0) return 0;
return Math.min(Math.max(Math.round(designGap * 0.5), 4), 48);
})();
if (processedRow.type === "fullwidth" && processedRow.mainComponent) {
return (
<div key={`row-${rowIndex}`} style={{ marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }}>
<FullWidthOverlayRow
main={processedRow.mainComponent}
overlayComps={processedRow.overlayComps}
canvasWidth={canvasWidth}
renderComponent={renderComponent}
/>
</div>
);
}
const { normalComps } = processedRow;
const allButtons = normalComps.every((c) => isButtonComponent(c));
// 데스크톱에서 버튼만 있는 행: 디자이너의 x, width를 비율로 적용
if (allButtons && normalComps.length > 0 && !isMobile) {
const rowHeight = Math.max(...normalComps.map(c => c.size?.height || 40));
return (
<div
key={`row-${rowIndex}`}
className="relative w-full flex-shrink-0"
style={{
height: `${rowHeight}px`,
marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined,
}}
>
{normalComps.map((component) => {
const typeId = getComponentTypeId(component);
const leftPct = (component.position.x / canvasWidth) * 100;
const widthPct = ((component.size?.width || 90) / canvasWidth) * 100;
return (
<div
key={component.id}
data-component-id={component.id}
data-component-type={typeId}
style={{
position: "absolute",
left: `${leftPct}%`,
width: `${widthPct}%`,
height: `${component.size?.height || 40}px`,
}}
>
{renderComponent(component)}
</div>
);
})}
</div>
);
}
const gap = isMobile ? 8 : getRowGap(normalComps, canvasWidth);
const hasFlexHeightComp = normalComps.some((c) => {
const h = c.size?.height || 0;
return h / canvasHeight >= 0.8;
});
return (
<div
key={`row-${rowIndex}`}
className={cn(
"flex w-full flex-wrap overflow-hidden",
hasFlexHeightComp ? "min-h-0 flex-1" : "flex-shrink-0"
)}
style={{ gap: `${gap}px`, marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }}
>
{normalComps.map((component) => {
const typeId = getComponentTypeId(component);
const isButton = isButtonComponent(component);
const isFullWidth = isMobile && !isButton;
if (isButton) {
return (
<div
key={component.id}
data-component-id={component.id}
data-component-type={typeId}
className="flex-shrink-0"
style={{
height: component.size?.height
? `${component.size.height}px`
: "40px",
}}
>
{renderComponent(component)}
</div>
);
}
const percentWidth = isFullWidth
? 100
: getPercentageWidth(component.size?.width || 100, canvasWidth);
const flexBasis = isFullWidth
? "100%"
: `calc(${percentWidth}% - ${gap}px)`;
const heightPct = (component.size?.height || 0) / canvasHeight;
const useFlexHeight = heightPct >= 0.8;
return (
<div
key={component.id}
data-component-id={component.id}
data-component-type={typeId}
className={cn("min-w-0 overflow-hidden", useFlexHeight && "min-h-0 flex-1")}
style={{
width: isFullWidth ? "100%" : undefined,
flexBasis: useFlexHeight ? undefined : flexBasis,
flexGrow: percentWidth,
flexShrink: 1,
minWidth: isMobile ? "100%" : undefined,
minHeight: useFlexHeight ? "300px" : (component.size?.height
? `${component.size.height}px`
: undefined),
height: useFlexHeight ? "100%" : "auto",
}}
>
{renderComponent(component)}
</div>
);
})}
</div>
);
})}
</div>
<ProportionalRenderer
components={components}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
renderComponent={renderComponent}
/>
);
}

View File

@ -8,7 +8,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -240,14 +239,14 @@ export function ScreenSettingModal({
componentCount = 0,
onSaveSuccess,
}: ScreenSettingModalProps) {
const [activeTab, setActiveTab] = useState("overview");
const [loading, setLoading] = useState(false);
const [dataFlows, setDataFlows] = useState<DataFlow[]>([]);
const [layoutItems, setLayoutItems] = useState<LayoutItem[]>([]);
const [iframeKey, setIframeKey] = useState(0); // iframe 새로고침용 키
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); // 화면 캔버스 크기
const [showDesignerModal, setShowDesignerModal] = useState(false); // 화면 디자이너 모달
const [showTableSettingModal, setShowTableSettingModal] = useState(false); // 테이블 설정 모달
const [buttonControls, setButtonControls] = useState<ButtonControlInfo[]>([]);
const [iframeKey, setIframeKey] = useState(0);
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
const [showDesignerModal, setShowDesignerModal] = useState(false);
const [showTableSettingModal, setShowTableSettingModal] = useState(false);
const [tableSettingTarget, setTableSettingTarget] = useState<{ tableName: string; tableLabel?: string } | null>(null);
// 그룹 내 화면 목록 및 현재 선택된 화면
@ -338,12 +337,56 @@ export function ScreenSettingModal({
if (layoutResponse.success && layoutResponse.data) {
const screenLayout = layoutResponse.data[currentScreenId];
setLayoutItems(screenLayout?.layoutItems || []);
// 캔버스 크기 저장 (화면 프리뷰에 사용)
setCanvasSize({
width: screenLayout?.canvasWidth || 0,
height: screenLayout?.canvasHeight || 0,
});
}
// 3. 버튼 정보 추출 (읽기 전용 요약용)
try {
const rawLayout = await screenApi.getLayout(currentScreenId);
if (rawLayout?.components) {
const buttons: ButtonControlInfo[] = [];
const extractButtons = (components: any[]) => {
for (const comp of components) {
const config = comp.componentConfig || {};
const isButton =
comp.widgetType === "button" || comp.webType === "button" ||
comp.type === "button" || config.webType === "button" ||
comp.componentType?.includes("button") || comp.componentKind?.includes("button");
if (isButton) {
const webTypeConfig = comp.webTypeConfig || {};
const action = config.action || {};
buttons.push({
id: comp.id || comp.componentId || `btn-${buttons.length}`,
label: config.text || comp.label || comp.title || comp.name || "버튼",
actionType: typeof action === "string" ? action : (action.type || "custom"),
confirmMessage: action.confirmationMessage || action.confirmMessage || config.confirmMessage,
confirmationEnabled: action.confirmationEnabled ?? (!!action.confirmationMessage || !!action.confirmMessage),
backgroundColor: webTypeConfig.backgroundColor || config.backgroundColor || comp.style?.backgroundColor,
textColor: webTypeConfig.textColor || config.textColor || comp.style?.color,
borderRadius: webTypeConfig.borderRadius || config.borderRadius || comp.style?.borderRadius,
linkedFlows: webTypeConfig.dataflowConfig?.flowConfigs?.map((fc: any) => ({
id: fc.flowId, name: fc.flowName, timing: fc.executionTiming || "after",
})) || (webTypeConfig.dataflowConfig?.flowConfig ? [{
id: webTypeConfig.dataflowConfig.flowConfig.flowId,
name: webTypeConfig.dataflowConfig.flowConfig.flowName,
timing: webTypeConfig.dataflowConfig.flowConfig.executionTiming || "after",
}] : []),
});
}
if (comp.children && Array.isArray(comp.children)) extractButtons(comp.children);
if (comp.componentConfig?.children && Array.isArray(comp.componentConfig.children)) extractButtons(comp.componentConfig.children);
if (comp.items && Array.isArray(comp.items)) extractButtons(comp.items);
}
};
extractButtons(rawLayout.components);
setButtonControls(buttons);
}
} catch (btnError) {
console.error("버튼 정보 추출 실패:", btnError);
}
} catch (error) {
console.error("데이터 로드 실패:", error);
} finally {
@ -360,162 +403,295 @@ export function ScreenSettingModal({
// 새로고침 (데이터 + iframe)
const handleRefresh = useCallback(() => {
loadData();
setIframeKey(prev => prev + 1); // iframe 새로고침
setIframeKey(prev => prev + 1);
}, [loadData]);
// 통계 계산
const stats = useMemo(() => {
const totalJoins = filterTables.reduce((sum, ft) => sum + (ft.joinColumnRefs?.length || 0), 0);
const layoutColumnsSet = new Set<string>();
layoutItems.forEach((item) => {
if (item.usedColumns) item.usedColumns.forEach((col) => layoutColumnsSet.add(col));
if (item.bindField) layoutColumnsSet.add(item.bindField);
});
const inputCount = layoutItems.filter(i => !i.widgetType?.includes("button") && !i.componentKind?.includes("table")).length;
const gridCount = layoutItems.filter(i => i.componentKind?.includes("table") || i.componentKind?.includes("grid")).length;
return {
tableCount: 1 + filterTables.length,
fieldCount: layoutColumnsSet.size || fieldMappings.length,
joinCount: totalJoins,
flowCount: dataFlows.length,
inputCount,
gridCount,
buttonCount: buttonControls.length,
};
}, [filterTables, fieldMappings, dataFlows, layoutItems, buttonControls]);
// 연결된 플로우 총 개수
const linkedFlowCount = useMemo(() => {
return buttonControls.reduce((sum, btn) => sum + (btn.linkedFlows?.length || 0), 0);
}, [buttonControls]);
return (
<>
<Dialog open={isOpen && !showDesignerModal} onOpenChange={onClose}>
<DialogContent className="flex h-[90vh] max-h-[950px] w-[98vw] max-w-[1600px] flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2 text-lg">
<Settings2 className="h-5 w-5 text-primary" />
:
{groupScreens.length > 1 ? (
<Select
value={currentScreenId.toString()}
onValueChange={(value) => handleScreenChange(parseInt(value, 10))}
>
<SelectTrigger className="h-8 w-auto min-w-[200px] max-w-[400px] text-base font-semibold">
<SelectValue placeholder="화면 선택" />
</SelectTrigger>
<SelectContent>
{groupScreens.map((screen) => (
<SelectItem key={screen.screen_id} value={screen.screen_id.toString()}>
{screen.screen_name}
{screen.screen_role && (
<span className="ml-2 text-xs text-muted-foreground">
({screen.screen_role})
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span>{currentScreenName}</span>
<DialogContent className="flex h-[90vh] max-h-[950px] w-[98vw] max-w-[1600px] flex-col overflow-hidden border-border/40 bg-background/95 backdrop-blur-xl">
{/* V3 Header */}
<DialogHeader className="flex-shrink-0 pb-0">
<DialogTitle className="flex items-center gap-3 text-base">
<span className="h-2 w-2 rounded-full bg-emerald-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]" />
<span className="font-bold tracking-tight">{currentScreenName}</span>
{groupScreens.length > 1 && (
<>
<span className="h-3.5 w-px bg-border" />
<Select
value={currentScreenId.toString()}
onValueChange={(value) => handleScreenChange(parseInt(value, 10))}
>
<SelectTrigger className="h-7 w-auto min-w-[140px] max-w-[280px] border-border/40 bg-muted/30 text-xs font-medium">
<SelectValue placeholder="화면 선택" />
</SelectTrigger>
<SelectContent>
{groupScreens.map((screen) => (
<SelectItem key={screen.screen_id} value={screen.screen_id.toString()}>
{screen.screen_name}
{screen.screen_role && (
<span className="ml-1 text-muted-foreground">({screen.screen_role})</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
<span className="ml-auto font-mono text-[10px] text-muted-foreground/60">#{currentScreenId}</span>
<Button variant="ghost" size="sm" onClick={handleRefresh} className="h-7 w-7 p-0" title="새로고침">
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
</Button>
</DialogTitle>
<DialogDescription className="text-sm">
, , .
</DialogDescription>
<DialogDescription className="sr-only"> </DialogDescription>
</DialogHeader>
{/* 2컬럼 레이아웃: 왼쪽 탭(좁게) + 오른쪽 프리뷰(넓게) */}
<div className="flex min-h-0 flex-1 gap-3">
{/* 왼쪽: 탭 컨텐츠 (40%) */}
<div className="flex min-h-0 w-[40%] flex-col rounded-lg border bg-card">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex min-h-0 flex-1 flex-col"
>
<div className="flex flex-shrink-0 items-center justify-between border-b p-2">
<TabsList className="h-8">
<TabsTrigger value="overview" className="gap-1 text-xs px-2">
<Database className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="table-setting" className="gap-1 text-xs px-2" disabled={!mainTable}>
<Settings2 className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="control-management" className="gap-1 text-xs px-2">
<Zap className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="data-flow" className="gap-1 text-xs px-2">
<GitBranch className="h-3 w-3" />
</TabsTrigger>
</TabsList>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
className="h-7 w-7 p-0"
title="새로고침"
>
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowDesignerModal(true)}
className="h-7 px-2 text-xs gap-1"
title="화면 디자이너에서 상세 편집"
>
<ExternalLink className="h-3 w-3" />
</Button>
{/* V3 Body: Left Info Panel + Right Preview */}
<div className="flex min-h-0 flex-1 gap-3 pt-2">
{/* 왼쪽: 정보 패널 (탭 없음, 단일 스크롤) */}
<div className="flex min-h-0 w-[380px] flex-shrink-0 flex-col rounded-lg border border-border/40">
<div className="flex-1 overflow-y-auto p-4 [&::-webkit-scrollbar]:w-[2px] [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/50">
{/* 1. 내러티브 요약 */}
<div className="mb-4 rounded-lg border border-primary/10 bg-gradient-to-br from-primary/[0.04] to-blue-500/[0.02] p-3">
<p className="text-xs leading-relaxed text-muted-foreground">
<span className="font-semibold text-foreground">{currentMainTable || "테이블 미연결"}</span>
{stats.fieldCount > 0 && <> <span className="font-bold text-primary">{stats.fieldCount}</span> .</>}
{filterTables.length > 0 && <><br /> {filterTables.length}{stats.joinCount > 0 && <>, {stats.joinCount}</>} .</>}
</p>
</div>
{/* 2. 속성 테이블 */}
<div className="mb-4 space-y-0.5">
<div className="flex h-[30px] items-center rounded-md px-2 text-[11px] transition-colors hover:bg-muted/30">
<span className="w-[76px] flex-shrink-0 text-[10px] font-medium text-muted-foreground"> </span>
<span className="min-w-0 flex-1 truncate font-mono text-[10px] font-semibold">{currentMainTable || "-"}</span>
{stats.fieldCount > 0 && <span className="flex-shrink-0 font-mono text-[9px] text-muted-foreground">{stats.fieldCount} </span>}
</div>
{filterTables.map((ft, idx) => (
<div key={idx} className="flex h-[30px] items-center rounded-md px-2 text-[11px] transition-colors hover:bg-muted/30">
<span className="w-[76px] flex-shrink-0 text-[10px] font-medium text-muted-foreground"> </span>
<span className="min-w-0 flex-1 truncate font-mono text-[10px] font-semibold text-emerald-500">{ft.tableName}</span>
<span className="flex-shrink-0 font-mono text-[9px] text-muted-foreground">FK</span>
</div>
))}
{filterTables.some(ft => ft.joinColumnRefs && ft.joinColumnRefs.length > 0) && (
<div className="flex h-[30px] items-center rounded-md px-2 text-[11px] transition-colors hover:bg-muted/30">
<span className="w-[76px] flex-shrink-0 text-[10px] font-medium text-muted-foreground"> </span>
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold">
{filterTables.flatMap(ft => ft.joinColumnRefs || []).map((j, i) => (
<span key={i}>{i > 0 && ", "}<span className="font-mono">{j.column}</span> <span className="font-mono text-amber-500">{j.refTable}</span></span>
))}
</span>
<span className="flex-shrink-0 font-mono text-[9px] text-muted-foreground">{stats.joinCount}</span>
</div>
)}
<div className="flex h-[30px] items-center rounded-md px-2 text-[11px] transition-colors hover:bg-muted/30">
<span className="w-[76px] flex-shrink-0 text-[10px] font-medium text-muted-foreground"></span>
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold">
{stats.inputCount > 0 && <> {stats.inputCount}</>}
{stats.gridCount > 0 && <>{stats.inputCount > 0 && " · "} {stats.gridCount}</>}
{stats.buttonCount > 0 && <>{(stats.inputCount > 0 || stats.gridCount > 0) && " · "} {stats.buttonCount}</>}
{stats.inputCount === 0 && stats.gridCount === 0 && stats.buttonCount === 0 && `${componentCount}`}
</span>
</div>
</div>
{/* 탭 1: 화면 개요 */}
<TabsContent value="overview" className="mt-0 min-h-0 flex-1 overflow-auto p-3">
<OverviewTab
screenId={currentScreenId}
screenName={currentScreenName}
mainTable={currentMainTable}
mainTableLabel={currentMainTableLabel}
filterTables={filterTables}
fieldMappings={fieldMappings}
componentCount={componentCount}
dataFlows={dataFlows}
layoutItems={layoutItems}
loading={loading}
onRefresh={handleRefresh}
onOpenTableSetting={handleOpenTableSetting}
/>
</TabsContent>
<div className="my-4 h-px bg-border/40" />
{/* 탭 2: 테이블 설정 */}
<TabsContent value="table-setting" className="mt-0 min-h-0 flex-1 overflow-hidden p-0">
{mainTable && (
<TableSettingModal
isOpen={true}
onClose={() => {}} // 탭에서는 닫기 불필요
tableName={mainTable}
tableLabel={mainTableLabel}
screenId={currentScreenId}
onSaveSuccess={handleRefresh}
isEmbedded={true} // 임베드 모드
/>
{/* 3. 테이블 섹션 */}
<div className="mb-4">
<div className="mb-2 flex items-center gap-1.5 border-b border-border/30 pb-2">
<div className="flex h-4 w-4 items-center justify-center rounded bg-blue-500/10">
<Database className="h-2.5 w-2.5 text-blue-500" />
</div>
<span className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></span>
<Badge variant="secondary" className="ml-auto h-4 rounded-full px-1.5 text-[9px] font-bold bg-blue-500/10 text-blue-500 hover:bg-blue-500/10">{stats.tableCount}</Badge>
</div>
<p className="mb-2 text-[10px] text-muted-foreground/70"> "설정" </p>
<div className="space-y-1">
{currentMainTable && (
<div className="flex items-center gap-2.5 rounded-lg border border-border/40 bg-muted/20 p-2.5 transition-colors hover:border-border/60 hover:bg-muted/30">
<div className="h-6 w-[3px] flex-shrink-0 rounded-full bg-blue-500" />
<div className="min-w-0 flex-1">
<div className="truncate font-mono text-[11px] font-semibold">{currentMainTable}</div>
<div className="text-[9px] text-muted-foreground"> · {stats.fieldCount} </div>
</div>
<Button variant="outline" size="sm" className="h-6 px-2.5 text-[10px] font-semibold border-border/50" onClick={() => handleOpenTableSetting(currentMainTable, currentMainTableLabel)}></Button>
</div>
)}
{filterTables.map((ft, idx) => (
<div key={idx} className="flex items-center gap-2.5 rounded-lg border border-border/40 bg-muted/20 p-2.5 transition-colors hover:border-border/60 hover:bg-muted/30">
<div className="h-6 w-[3px] flex-shrink-0 rounded-full bg-emerald-500" />
<div className="min-w-0 flex-1">
<div className="truncate font-mono text-[11px] font-semibold">{ft.tableName}</div>
<div className="text-[9px] text-muted-foreground">{ft.filterKeyMapping ? ` · FK: ${ft.filterKeyMapping.filterTableColumn}` : ""}</div>
</div>
<Button variant="outline" size="sm" className="h-6 px-2.5 text-[10px] font-semibold border-border/50" onClick={() => handleOpenTableSetting(ft.tableName, ft.tableLabel)}></Button>
</div>
))}
</div>
</div>
<div className="my-4 h-px bg-border/40" />
{/* 4. 버튼 섹션 (읽기 전용) */}
<div className="mb-4">
<div className="mb-2 flex items-center gap-1.5 border-b border-border/30 pb-2">
<div className="flex h-4 w-4 items-center justify-center rounded bg-amber-500/10">
<MousePointer className="h-2.5 w-2.5 text-amber-500" />
</div>
<span className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></span>
<Badge variant="secondary" className="ml-auto h-4 rounded-full px-1.5 text-[9px] font-bold bg-amber-500/10 text-amber-500 hover:bg-amber-500/10">{stats.buttonCount}</Badge>
</div>
<p className="mb-2 text-[10px] text-muted-foreground/70"> </p>
{buttonControls.length > 0 ? (
<div className="space-y-0">
{buttonControls.map((btn) => (
<div key={btn.id} className="flex items-center gap-2 border-b border-border/20 py-1.5 last:border-b-0">
<span
className="flex h-5 flex-shrink-0 items-center rounded px-2 text-[9px] font-bold"
style={{
backgroundColor: btn.backgroundColor ? `${btn.backgroundColor}20` : "hsl(var(--muted))",
color: btn.textColor || btn.backgroundColor || "hsl(var(--foreground))",
}}
>{btn.label}</span>
<div className="min-w-0 flex-1">
<div className="text-[10px] text-muted-foreground">{btn.actionType?.toUpperCase() || "CUSTOM"}</div>
{btn.confirmMessage && <div className="truncate text-[9px] italic text-muted-foreground/60">"{btn.confirmMessage}"</div>}
</div>
{btn.linkedFlows && btn.linkedFlows.length > 0 && (
<Badge variant="secondary" className="h-4 rounded px-1.5 text-[8px] font-bold bg-violet-500/10 text-violet-500 hover:bg-violet-500/10">
{btn.linkedFlows.length}
</Badge>
)}
</div>
))}
</div>
) : (
<div className="py-4 text-center text-[10px] text-muted-foreground/50"> </div>
)}
</TabsContent>
</div>
{/* 탭 3: 제어 관리 */}
<TabsContent value="control-management" className="mt-0 min-h-0 flex-1 overflow-auto p-3">
<ControlManagementTab
screenId={currentScreenId}
groupId={groupId}
layoutItems={layoutItems}
loading={loading}
onRefresh={handleRefresh}
/>
</TabsContent>
<div className="my-4 h-px bg-border/40" />
{/* 탭 3: 데이터 흐름 */}
<TabsContent value="data-flow" className="mt-0 min-h-0 flex-1 overflow-auto p-3">
<DataFlowTab
screenId={currentScreenId}
groupId={groupId}
dataFlows={dataFlows}
loading={loading}
onReload={loadData}
onSaveSuccess={onSaveSuccess}
/>
</TabsContent>
</Tabs>
{/* 5. 데이터 흐름 섹션 */}
<div className="mb-4">
<div className="mb-2 flex items-center gap-1.5 border-b border-border/30 pb-2">
<div className="flex h-4 w-4 items-center justify-center rounded bg-rose-500/10">
<ArrowRight className="h-2.5 w-2.5 text-rose-500" />
</div>
<span className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"> </span>
<Badge variant="secondary" className="ml-auto h-4 rounded-full px-1.5 text-[9px] font-bold bg-rose-500/10 text-rose-500 hover:bg-rose-500/10">{stats.flowCount}</Badge>
</div>
{dataFlows.length > 0 ? (
<div className="space-y-1">
{dataFlows.map((flow) => (
<div key={flow.id} className="flex items-center gap-2.5 rounded-lg border border-border/40 bg-muted/20 p-2.5">
<div className="h-6 w-[3px] flex-shrink-0 rounded-full bg-rose-500" />
<div className="min-w-0 flex-1">
<div className="truncate text-[11px] font-semibold">{flow.source_action || flow.flow_type} {flow.target_screen_name || `화면 ${flow.target_screen_id}`}</div>
<div className="text-[9px] text-muted-foreground">{flow.flow_type}{flow.flow_label ? ` · ${flow.flow_label}` : ""}</div>
</div>
<Button variant="ghost" size="sm" className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive" onClick={async () => {
if (!confirm("정말 삭제하시겠습니까?")) return;
const res = await deleteDataFlow(flow.id);
if (res.success) { toast.success("삭제되었습니다."); loadData(); onSaveSuccess?.(); }
else toast.error("삭제 실패");
}}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
) : (
<div className="rounded-lg border border-dashed border-border/40 py-5 text-center">
<ArrowRight className="mx-auto mb-1 h-4 w-4 text-muted-foreground/30" />
<div className="text-[11px] font-medium text-muted-foreground/60"> </div>
<div className="text-[9px] text-muted-foreground/40"> </div>
</div>
)}
<Button variant="outline" size="sm" className="mt-2 h-7 w-full text-[10px] font-semibold border-border/40 text-muted-foreground" onClick={() => toast.info("데이터 흐름 추가는 준비 중이에요")}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="my-4 h-px bg-border/40" />
{/* 6. 플로우 연동 섹션 */}
<div>
<div className="mb-2 flex items-center gap-1.5 border-b border-border/30 pb-2">
<div className="flex h-4 w-4 items-center justify-center rounded bg-violet-500/10">
<Link2 className="h-2.5 w-2.5 text-violet-500" />
</div>
<span className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"> </span>
<Badge variant="secondary" className="ml-auto h-4 rounded-full px-1.5 text-[9px] font-bold bg-violet-500/10 text-violet-500 hover:bg-violet-500/10">{linkedFlowCount}</Badge>
</div>
{linkedFlowCount > 0 ? (
<div className="space-y-1">
{buttonControls.filter(b => b.linkedFlows && b.linkedFlows.length > 0).flatMap(btn =>
(btn.linkedFlows || []).map(flow => (
<div key={`${btn.id}-${flow.id}`} className="flex items-center gap-2.5 rounded-lg border border-violet-500/10 bg-violet-500/[0.03] p-2.5">
<div className="h-6 w-[3px] flex-shrink-0 rounded-full bg-violet-500" />
<div className="min-w-0 flex-1">
<div className="truncate text-[11px] font-semibold">{flow.name || `플로우 #${flow.id}`}</div>
<div className="text-[9px] text-muted-foreground">{btn.label} · {flow.timing === "before" ? "실행 전" : "실행 후"}</div>
</div>
</div>
))
)}
</div>
) : (
<div className="py-3 text-center text-[10px] text-muted-foreground/50"> </div>
)}
</div>
</div>
{/* CTA: 화면 디자이너 열기 */}
<div className="flex-shrink-0 border-t border-border/40 p-3">
<Button
className="h-8 w-full gap-1.5 bg-gradient-to-r from-primary to-blue-500 text-[11px] font-bold tracking-tight text-primary-foreground shadow-md shadow-primary/20 transition-all hover:shadow-lg hover:shadow-primary/30"
onClick={() => setShowDesignerModal(true)}
>
<ExternalLink className="h-3 w-3" />
</Button>
</div>
</div>
{/* 오른쪽: 화면 프리뷰 (60%, 항상 표시) */}
<div className="flex min-h-0 w-[60%] flex-col overflow-hidden rounded-lg border bg-card">
<PreviewTab
screenId={currentScreenId}
screenName={currentScreenName}
companyCode={companyCode}
{/* 오른쪽: 화면 프리뷰 */}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-border/40">
<PreviewTab
screenId={currentScreenId}
screenName={currentScreenName}
companyCode={companyCode}
iframeKey={iframeKey}
canvasWidth={canvasSize.width}
canvasHeight={canvasSize.height}

View File

@ -102,7 +102,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
const fetchScreens = async () => {
try {
setScreensLoading(true);
const response = await apiClient.get("/screen-management/screens");
const response = await apiClient.get("/screen-management/screens?size=1000");
if (response.data.success && Array.isArray(response.data.data)) {
const screenList = response.data.data.map((screen: any) => ({

View File

@ -34,7 +34,8 @@ const FORMAT_PATTERNS: Record<V2InputFormat, { pattern: RegExp; placeholder: str
errorMessage: "올바른 이메일 형식이 아닙니다",
},
tel: {
pattern: /^\d{2,3}-\d{3,4}-\d{4}$/,
pattern:
/^(01[016789]|02|0[3-7]1|0[3-6][2-5]|050[2-8]|070|080)-\d{3,4}-\d{4}$|^(15|16|18)\d{2}-\d{4}$/,
placeholder: "010-1234-5678",
errorMessage: "올바른 전화번호 형식이 아닙니다",
},
@ -80,8 +81,34 @@ function formatBizNo(value: string): string {
// 전화번호 형식 변환
function formatTel(value: string): string {
const digits = value.replace(/\D/g, "");
if (digits.length === 0) return "";
// 대표번호: 15xx, 16xx, 18xx → 4-4
if (/^(15|16|18)/.test(digits)) {
if (digits.length <= 4) return digits;
return `${digits.slice(0, 4)}-${digits.slice(4, 8)}`;
}
// 서울: 02 → 2-4-4
if (digits.startsWith("02")) {
if (digits.length <= 2) return digits;
if (digits.length <= 6) return `${digits.slice(0, 2)}-${digits.slice(2)}`;
if (digits.length <= 10) return `${digits.slice(0, 2)}-${digits.slice(2, 6)}-${digits.slice(6)}`;
return `${digits.slice(0, 2)}-${digits.slice(2, 6)}-${digits.slice(6, 10)}`;
}
// 안심번호: 050x → 4-4-4
if (/^050[2-8]/.test(digits)) {
if (digits.length <= 4) return digits;
if (digits.length <= 8) return `${digits.slice(0, 4)}-${digits.slice(4)}`;
if (digits.length <= 12) return `${digits.slice(0, 4)}-${digits.slice(4, 8)}-${digits.slice(8)}`;
return `${digits.slice(0, 4)}-${digits.slice(4, 8)}-${digits.slice(8, 12)}`;
}
// 나머지 (010, 031, 070, 080 등)
if (digits.length <= 3) return digits;
if (digits.length <= 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
if (digits.length === 10) return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`;
if (digits.length <= 11) return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7, 11)}`;
}
@ -1128,7 +1155,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
const hasCustomRadius = !!style?.borderRadius;
const customTextStyle: React.CSSProperties = {};
if (style?.color) customTextStyle.color = style.color;
if (style?.color) customTextStyle.color = getAdaptiveLabelColor(style.color);
if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight;
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
@ -1175,8 +1202,8 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
ref={ref}
id={id}
className={cn(
"flex flex-col gap-1",
labelPos === "left" ? "sm:flex-row sm:items-center" : "sm:flex-row-reverse sm:items-center",
"flex gap-1",
labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center",
)}
style={{
width: componentWidth,
@ -1191,7 +1218,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
color: getAdaptiveLabelColor(style?.labelColor),
fontWeight: style?.labelFontWeight || "500",
}}
className="w-full text-sm font-medium whitespace-nowrap sm:w-[120px] sm:shrink-0"
className="text-sm font-medium whitespace-nowrap w-[120px] shrink-0"
>
{actualLabel}
{required && <span className="ml-0.5 text-amber-500">*</span>}

View File

@ -1291,8 +1291,8 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>((props, ref) =
ref={ref}
id={id}
className={cn(
"flex flex-col gap-1",
labelPos === "left" ? "sm:flex-row sm:items-center" : "sm:flex-row-reverse sm:items-center",
"flex gap-1",
labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center",
isDesignMode && "pointer-events-none",
)}
style={{
@ -1308,7 +1308,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>((props, ref) =
color: getAdaptiveLabelColor(style?.labelColor),
fontWeight: style?.labelFontWeight || "500",
}}
className="w-full text-sm font-medium whitespace-nowrap sm:w-[120px] sm:shrink-0"
className="text-sm font-medium whitespace-nowrap w-[120px] shrink-0"
>
{label}
{required && <span className="ml-0.5 text-amber-500">*</span>}

View File

@ -269,6 +269,13 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
const [modalScreenOpen, setModalScreenOpen] = useState(false);
const [modalSearchTerm, setModalSearchTerm] = useState("");
// 데이터 전달 필드 매핑 관련
const [availableTables, setAvailableTables] = useState<Array<{ name: string; label: string }>>([]);
const [mappingSourceColumnsMap, setMappingSourceColumnsMap] = useState<Record<string, Array<{ name: string; label: string }>>>({});
const [mappingTargetColumns, setMappingTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
const [fieldMappingOpen, setFieldMappingOpen] = useState(false);
const [activeMappingGroupIndex, setActiveMappingGroupIndex] = useState(0);
const showIconSettings = displayMode === "icon" || displayMode === "icon-text";
const currentActionIcons = actionIconMap[actionType] || [];
const isNoIconAction = noIconActions.has(actionType);
@ -330,6 +337,76 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
setIconSize(config.icon?.size || "보통");
}, [config.icon?.name, config.icon?.type, config.icon?.size]);
// 테이블 목록 로드 (데이터 전달 액션용)
useEffect(() => {
if (actionType !== "transferData") return;
if (availableTables.length > 0) return;
const loadTables = async () => {
try {
const response = await apiClient.get("/table-management/tables");
if (response.data.success && response.data.data) {
const tables = response.data.data.map((t: any) => ({
name: t.tableName || t.name,
label: t.displayName || t.tableLabel || t.label || t.tableName || t.name,
}));
setAvailableTables(tables);
}
} catch {
setAvailableTables([]);
}
};
loadTables();
}, [actionType, availableTables.length]);
// 테이블 컬럼 로드 헬퍼
const loadTableColumns = useCallback(async (tableName: string): Promise<Array<{ name: string; label: string }>> => {
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
if (response.data.success) {
let columnData = response.data.data;
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
if (Array.isArray(columnData)) {
return columnData.map((col: any) => ({
name: col.name || col.columnName,
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
}));
}
}
} catch { /* ignore */ }
return [];
}, []);
// 멀티 테이블 매핑: 소스/타겟 테이블 컬럼 로드
useEffect(() => {
if (actionType !== "transferData") return;
const multiTableMappings: Array<{ sourceTable: string }> = config.action?.dataTransfer?.multiTableMappings || [];
const targetTable = config.action?.dataTransfer?.targetTable;
const loadAll = async () => {
const sourceTableNames = multiTableMappings.map((m) => m.sourceTable).filter(Boolean);
const newMap: Record<string, Array<{ name: string; label: string }>> = {};
for (const tbl of sourceTableNames) {
if (!mappingSourceColumnsMap[tbl]) {
newMap[tbl] = await loadTableColumns(tbl);
}
}
if (Object.keys(newMap).length > 0) {
setMappingSourceColumnsMap((prev) => ({ ...prev, ...newMap }));
}
if (targetTable) {
const cols = await loadTableColumns(targetTable);
setMappingTargetColumns(cols);
} else {
setMappingTargetColumns([]);
}
};
loadAll();
}, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, loadTableColumns]);
// 화면 목록 로드 (모달 액션용)
useEffect(() => {
if (actionType !== "modal" && actionType !== "navigate") return;
@ -338,7 +415,7 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
const loadScreens = async () => {
setScreensLoading(true);
try {
const response = await apiClient.get("/screen-management/screens");
const response = await apiClient.get("/screen-management/screens?size=1000");
if (response.data.success && response.data.data) {
const screenList = response.data.data.map((s: any) => ({
id: s.id || s.screenId,
@ -521,6 +598,8 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
modalSearchTerm={modalSearchTerm}
setModalSearchTerm={setModalSearchTerm}
currentTableName={effectiveTableName}
allComponents={allComponents}
handleUpdateProperty={handleUpdateProperty}
/>
{/* ─── 아이콘 설정 (접기) ─── */}
@ -657,6 +736,26 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
)}
</div>
{/* 데이터 전달 필드 매핑 (transferData 액션 전용) */}
{actionType === "transferData" && (
<>
<Separator />
<TransferDataFieldMappingSection
config={config}
onChange={onChange}
availableTables={availableTables}
mappingSourceColumnsMap={mappingSourceColumnsMap}
setMappingSourceColumnsMap={setMappingSourceColumnsMap}
mappingTargetColumns={mappingTargetColumns}
fieldMappingOpen={fieldMappingOpen}
setFieldMappingOpen={setFieldMappingOpen}
activeMappingGroupIndex={activeMappingGroupIndex}
setActiveMappingGroupIndex={setActiveMappingGroupIndex}
loadTableColumns={loadTableColumns}
/>
</>
)}
{/* 제어 기능 */}
{actionType !== "excel_upload" && actionType !== "multi_table_excel_upload" && (
<>
@ -699,6 +798,8 @@ const ActionDetailSection: React.FC<{
modalSearchTerm: string;
setModalSearchTerm: (term: string) => void;
currentTableName?: string;
allComponents?: ComponentData[];
handleUpdateProperty?: (path: string, value: any) => void;
}> = ({
actionType,
config,
@ -711,6 +812,8 @@ const ActionDetailSection: React.FC<{
modalSearchTerm,
setModalSearchTerm,
currentTableName,
allComponents = [],
handleUpdateProperty,
}) => {
const action = config.action || {};
@ -800,7 +903,7 @@ const ActionDetailSection: React.FC<{
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<Command shouldFilter={false}>
<CommandInput
placeholder="화면 검색..."
value={modalSearchTerm}
@ -812,8 +915,10 @@ const ActionDetailSection: React.FC<{
<CommandGroup>
{screens
.filter((s) =>
!modalSearchTerm ||
s.name.toLowerCase().includes(modalSearchTerm.toLowerCase()) ||
s.description?.toLowerCase().includes(modalSearchTerm.toLowerCase())
s.description?.toLowerCase().includes(modalSearchTerm.toLowerCase()) ||
String(s.id).includes(modalSearchTerm)
)
.map((screen) => (
<CommandItem
@ -951,6 +1056,190 @@ const ActionDetailSection: React.FC<{
</div>
);
case "transferData":
return (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<SendHorizontal className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
{/* 소스 컴포넌트 선택 */}
<div>
<Label className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Select
value={action.dataTransfer?.sourceComponentId || ""}
onValueChange={(v) => {
const dt = { ...action.dataTransfer, sourceComponentId: v };
updateActionConfig("dataTransfer", dt);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__auto__">
<span className="text-xs font-medium"> ( )</span>
</SelectItem>
{allComponents
.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
type.includes(t)
);
})
.map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown";
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
return (
<SelectItem key={comp.id} value={comp.id}>
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{compLabel}</span>
<span className="text-muted-foreground text-[10px]">({compType})</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{/* 타겟 타입 */}
<div>
<Label className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Select
value={action.dataTransfer?.targetType || "component"}
onValueChange={(v) => {
const dt = { ...action.dataTransfer, targetType: v };
updateActionConfig("dataTransfer", dt);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="component"> </SelectItem>
<SelectItem value="splitPanel"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 타겟 컴포넌트 선택 */}
{action.dataTransfer?.targetType === "component" && (
<div>
<Label className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Select
value={action.dataTransfer?.targetComponentId || ""}
onValueChange={(v) => {
const dt = { ...action.dataTransfer, targetComponentId: v };
updateActionConfig("dataTransfer", dt);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="데이터를 받을 컴포넌트 선택" />
</SelectTrigger>
<SelectContent>
{allComponents
.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
(t) => type.includes(t)
);
return isReceivable && comp.id !== action.dataTransfer?.sourceComponentId;
})
.map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown";
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
return (
<SelectItem key={comp.id} value={comp.id}>
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{compLabel}</span>
<span className="text-muted-foreground text-[10px]">({compType})</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
)}
{/* 데이터 전달 모드 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={action.dataTransfer?.mode || "append"}
onValueChange={(v) => {
const dt = { ...action.dataTransfer, mode: v };
updateActionConfig("dataTransfer", dt);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="append"> (Append)</SelectItem>
<SelectItem value="replace"> (Replace)</SelectItem>
<SelectItem value="merge"> (Merge)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 전달 후 초기화 */}
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium"> </p>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
<Switch
checked={action.dataTransfer?.clearAfterTransfer || false}
onCheckedChange={(checked) => {
const dt = { ...action.dataTransfer, clearAfterTransfer: checked };
updateActionConfig("dataTransfer", dt);
}}
/>
</div>
{/* 전달 전 확인 */}
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium"> </p>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
<Switch
checked={action.dataTransfer?.confirmBeforeTransfer || false}
onCheckedChange={(checked) => {
const dt = { ...action.dataTransfer, confirmBeforeTransfer: checked };
updateActionConfig("dataTransfer", dt);
}}
/>
</div>
{action.dataTransfer?.confirmBeforeTransfer && (
<div>
<Label className="text-xs"> </Label>
<Input
value={action.dataTransfer?.confirmMessage || ""}
onChange={(e) => {
const dt = { ...action.dataTransfer, confirmMessage: e.target.value };
updateActionConfig("dataTransfer", dt);
}}
placeholder="선택한 항목을 전달하시겠습니까?"
className="h-7 text-xs"
/>
</div>
)}
{commonMessageSection}
</div>
);
case "event":
return (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
@ -1373,6 +1662,386 @@ const IconSettingsSection: React.FC<{
);
};
// ─── 데이터 전달 필드 매핑 서브 컴포넌트 (고급 설정 내부) ───
const TransferDataFieldMappingSection: React.FC<{
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
availableTables: Array<{ name: string; label: string }>;
mappingSourceColumnsMap: Record<string, Array<{ name: string; label: string }>>;
setMappingSourceColumnsMap: React.Dispatch<React.SetStateAction<Record<string, Array<{ name: string; label: string }>>>>;
mappingTargetColumns: Array<{ name: string; label: string }>;
fieldMappingOpen: boolean;
setFieldMappingOpen: (open: boolean) => void;
activeMappingGroupIndex: number;
setActiveMappingGroupIndex: (index: number) => void;
loadTableColumns: (tableName: string) => Promise<Array<{ name: string; label: string }>>;
}> = ({
config,
onChange,
availableTables,
mappingSourceColumnsMap,
setMappingSourceColumnsMap,
mappingTargetColumns,
activeMappingGroupIndex,
setActiveMappingGroupIndex,
loadTableColumns,
}) => {
const [sourcePopoverOpen, setSourcePopoverOpen] = useState<Record<string, boolean>>({});
const [targetPopoverOpen, setTargetPopoverOpen] = useState<Record<string, boolean>>({});
const dataTransfer = config.action?.dataTransfer || {};
const multiTableMappings: Array<{ sourceTable: string; mappingRules: Array<{ sourceField: string; targetField: string }> }> =
dataTransfer.multiTableMappings || [];
const updateDataTransfer = (field: string, value: any) => {
const currentAction = config.action || {};
const currentDt = currentAction.dataTransfer || {};
onChange({
...config,
action: {
...currentAction,
dataTransfer: { ...currentDt, [field]: value },
},
});
};
const activeGroup = multiTableMappings[activeMappingGroupIndex];
const activeSourceTable = activeGroup?.sourceTable || "";
const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || [];
const activeRules = activeGroup?.mappingRules || [];
const updateGroupField = (field: string, value: any) => {
const mappings = [...multiTableMappings];
mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value };
updateDataTransfer("multiTableMappings", mappings);
};
return (
<div className="space-y-3">
<div className="space-y-0.5">
<p className="text-sm font-medium"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
{/* 타겟 테이블 (공통) */}
<div>
<Label className="text-xs"> ()</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
{dataTransfer.targetTable
? availableTables.find((t) => t.name === dataTransfer.targetTable)?.label ||
dataTransfer.targetTable
: "타겟 테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList className="max-h-48">
<CommandEmpty className="py-3 text-xs"> </CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
key={table.name}
value={`${table.label} ${table.name}`}
onSelect={() => updateDataTransfer("targetTable", table.name)}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", dataTransfer.targetTable === table.name ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{table.label}</span>
{table.label !== table.name && <span className="text-[10px] text-muted-foreground">{table.name}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 소스 테이블 그룹 탭 + 추가 버튼 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => {
updateDataTransfer("multiTableMappings", [
...multiTableMappings,
{ sourceTable: "", mappingRules: [] },
]);
setActiveMappingGroupIndex(multiTableMappings.length);
}}
disabled={!dataTransfer.targetTable}
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
{!dataTransfer.targetTable ? (
<div className="rounded-lg border border-dashed p-4 text-center">
<p className="text-xs text-muted-foreground"> </p>
</div>
) : multiTableMappings.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-center">
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-3">
{/* 그룹 탭 */}
<div className="flex flex-wrap gap-1.5">
{multiTableMappings.map((group, gIdx) => (
<div key={gIdx} className="flex items-center">
<Button
type="button"
variant={activeMappingGroupIndex === gIdx ? "default" : "outline"}
size="sm"
className="h-7 text-xs rounded-r-none"
onClick={() => setActiveMappingGroupIndex(gIdx)}
>
{group.sourceTable
? availableTables.find((t) => t.name === group.sourceTable)?.label || group.sourceTable
: `그룹 ${gIdx + 1}`}
{group.mappingRules?.length > 0 && (
<span className="ml-1.5 rounded-full bg-primary-foreground/20 px-1.5 text-[10px]">
{group.mappingRules.length}
</span>
)}
</Button>
<Button
type="button"
variant={activeMappingGroupIndex === gIdx ? "default" : "outline"}
size="icon"
className="h-7 w-7 rounded-l-none border-l-0"
onClick={() => {
const mappings = [...multiTableMappings];
mappings.splice(gIdx, 1);
updateDataTransfer("multiTableMappings", mappings);
if (activeMappingGroupIndex >= mappings.length) {
setActiveMappingGroupIndex(Math.max(0, mappings.length - 1));
}
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
{/* 활성 그룹 편집 */}
{activeGroup && (
<div className="space-y-3 rounded-lg border p-3">
{/* 소스 테이블 선택 */}
<div>
<Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
{activeSourceTable
? availableTables.find((t) => t.name === activeSourceTable)?.label || activeSourceTable
: "소스 테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList className="max-h-48">
<CommandEmpty className="py-3 text-xs"> </CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
key={table.name}
value={`${table.label} ${table.name}`}
onSelect={async () => {
updateGroupField("sourceTable", table.name);
if (!mappingSourceColumnsMap[table.name]) {
const cols = await loadTableColumns(table.name);
setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols }));
}
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", activeSourceTable === table.name ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{table.label}</span>
{table.label !== table.name && <span className="text-[10px] text-muted-foreground">{table.name}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 매핑 규칙 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => {
updateGroupField("mappingRules", [
...activeRules,
{ sourceField: "", targetField: "" },
]);
}}
disabled={!activeSourceTable}
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
{!activeSourceTable ? (
<p className="text-xs text-muted-foreground"> </p>
) : activeRules.length === 0 ? (
<p className="text-xs text-muted-foreground"> ( )</p>
) : (
<div className="space-y-2">
{activeRules.map((rule: any, rIdx: number) => {
const keyS = `${activeMappingGroupIndex}-${rIdx}-s`;
const keyT = `${activeMappingGroupIndex}-${rIdx}-t`;
return (
<div
key={rIdx}
className="grid items-center gap-1.5"
style={{ gridTemplateColumns: "1fr 16px 1fr 32px" }}
>
{/* 소스 필드 */}
<Popover
open={sourcePopoverOpen[keyS] || false}
onOpenChange={(open) => setSourcePopoverOpen((prev) => ({ ...prev, [keyS]: open }))}
>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs overflow-hidden">
<span className="truncate">
{rule.sourceField
? activeSourceColumns.find((c) => c.name === rule.sourceField)?.label || rule.sourceField
: "소스 컬럼"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[220px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandList className="max-h-48">
<CommandEmpty className="py-3 text-xs"> </CommandEmpty>
<CommandGroup>
{activeSourceColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
const newRules = [...activeRules];
newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name };
updateGroupField("mappingRules", newRules);
setSourcePopoverOpen((prev) => ({ ...prev, [keyS]: false }));
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", rule.sourceField === col.name ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{col.label}</span>
{col.label !== col.name && <span className="ml-1 text-muted-foreground">({col.name})</span>}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<ArrowRight className="mx-auto h-4 w-4 text-muted-foreground" />
{/* 타겟 필드 */}
<Popover
open={targetPopoverOpen[keyT] || false}
onOpenChange={(open) => setTargetPopoverOpen((prev) => ({ ...prev, [keyT]: open }))}
>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs overflow-hidden">
<span className="truncate">
{rule.targetField
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label || rule.targetField
: "타겟 컬럼"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[220px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandList className="max-h-48">
<CommandEmpty className="py-3 text-xs"> </CommandEmpty>
<CommandGroup>
{mappingTargetColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
const newRules = [...activeRules];
newRules[rIdx] = { ...newRules[rIdx], targetField: col.name };
updateGroupField("mappingRules", newRules);
setTargetPopoverOpen((prev) => ({ ...prev, [keyT]: false }));
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", rule.targetField === col.name ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{col.label}</span>
{col.label !== col.name && <span className="ml-1 text-muted-foreground">({col.name})</span>}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* 삭제 */}
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:bg-destructive/10"
onClick={() => {
const newRules = [...activeRules];
newRules.splice(rIdx, 1);
updateGroupField("mappingRules", newRules);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
};
V2ButtonConfigPanel.displayName = "V2ButtonConfigPanel";
export default V2ButtonConfigPanel;

View File

@ -82,9 +82,10 @@ import {
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type {
SplitPanelLayoutConfig,
AdditionalTabConfig,
import {
MAX_LOAD_ALL_SIZE,
type SplitPanelLayoutConfig,
type AdditionalTabConfig,
} from "@/lib/registry/components/v2-split-panel-layout/types";
import type { TableInfo, ColumnInfo } from "@/types/screen";
@ -1158,6 +1159,41 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
updateLeftPanel({ showItemAddButton: checked })
}
/>
<SwitchRow
label="페이징 처리"
description="서버 페이지 단위 조회 (필터/정렬/계층 비활성화)"
checked={config.leftPanel?.pagination?.enabled ?? false}
onCheckedChange={(checked) =>
updateLeftPanel({
pagination: {
...config.leftPanel?.pagination,
enabled: checked,
pageSize: config.leftPanel?.pagination?.pageSize ?? 20,
},
})
}
/>
{config.leftPanel?.pagination?.enabled && (
<div className="ml-4 space-y-1">
<Label className="text-[10px]"> </Label>
<Input
type="number"
min={1}
max={MAX_LOAD_ALL_SIZE}
value={config.leftPanel?.pagination?.pageSize ?? 20}
onChange={(e) =>
updateLeftPanel({
pagination: {
...config.leftPanel?.pagination,
enabled: true,
pageSize: Math.min(MAX_LOAD_ALL_SIZE, Math.max(1, Number(e.target.value) || 20)),
},
})
}
className="h-7 w-24 text-xs"
/>
</div>
)}
</div>
{/* 좌측 패널 컬럼 설정 (접이식) */}
@ -1564,6 +1600,41 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
updateRightPanel({ showDelete: checked })
}
/>
<SwitchRow
label="페이징 처리"
description="서버 페이지 단위 조회 (탭 포함 적용)"
checked={config.rightPanel?.pagination?.enabled ?? false}
onCheckedChange={(checked) =>
updateRightPanel({
pagination: {
...config.rightPanel?.pagination,
enabled: checked,
pageSize: config.rightPanel?.pagination?.pageSize ?? 20,
},
})
}
/>
{config.rightPanel?.pagination?.enabled && (
<div className="ml-4 space-y-1">
<Label className="text-[10px]"> </Label>
<Input
type="number"
min={1}
max={MAX_LOAD_ALL_SIZE}
value={config.rightPanel?.pagination?.pageSize ?? 20}
onChange={(e) =>
updateRightPanel({
pagination: {
...config.rightPanel?.pagination,
enabled: true,
pageSize: Math.min(MAX_LOAD_ALL_SIZE, Math.max(1, Number(e.target.value) || 20)),
},
})
}
className="h-7 w-24 text-xs"
/>
</div>
)}
</div>
{/* 우측 패널 컬럼 설정 (접이식) */}

View File

@ -957,7 +957,38 @@ export const V2TableListConfigPanel: React.FC<V2TableListConfigPanelProps> = ({
/>
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
<span className="truncate text-xs">{column.columnLabel}</span>
<span className="ml-auto text-[10px] text-primary/80">
{isAlreadyAdded && (
<button
type="button"
title={
config.columns?.find((c) => c.columnName === matchingJoinColumn.joinAlias)?.editable === false
? "편집 잠금 (클릭하여 해제)"
: "편집 가능 (클릭하여 잠금)"
}
className={cn(
"ml-auto flex-shrink-0 rounded p-0.5 transition-colors",
config.columns?.find((c) => c.columnName === matchingJoinColumn.joinAlias)?.editable === false
? "text-destructive hover:bg-destructive/10"
: "text-muted-foreground hover:bg-muted",
)}
onClick={(e) => {
e.stopPropagation();
const currentCol = config.columns?.find((c) => c.columnName === matchingJoinColumn.joinAlias);
if (currentCol) {
updateColumn(matchingJoinColumn.joinAlias, {
editable: currentCol.editable === false ? undefined : false,
});
}
}}
>
{config.columns?.find((c) => c.columnName === matchingJoinColumn.joinAlias)?.editable === false ? (
<Lock className="h-3 w-3" />
) : (
<Unlock className="h-3 w-3" />
)}
</button>
)}
<span className={cn("text-[10px] text-primary/80", !isAlreadyAdded && "ml-auto")}>
{column.inputType || column.dataType}
</span>
</div>

View File

@ -102,12 +102,16 @@ function mergeColumnMeta(tableName: string | undefined, columnName: string | und
if (dbInputType === "entity") {
const refTable = meta.reference_table || meta.referenceTable;
const refColumn = meta.reference_column || meta.referenceColumn;
const displayCol = meta.display_column || meta.displayColumn;
const rawDisplayCol = meta.display_column || meta.displayColumn;
const displayCol = rawDisplayCol && rawDisplayCol !== "none" && rawDisplayCol !== "" ? rawDisplayCol : undefined;
if (refTable) {
merged.source = "entity";
merged.entityTable = refTable;
merged.entityValueColumn = refColumn || "id";
merged.entityLabelColumn = displayCol || "name";
// 화면 설정에 이미 entityLabelColumn이 있으면 유지, 없으면 DB 값 또는 기본값 사용
if (!merged.entityLabelColumn) {
merged.entityLabelColumn = displayCol || "name";
}
merged.fieldType = "entity";
merged.inputType = "entity";
}
@ -531,7 +535,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
}
: (component as any).style;
const catSize = catNeedsExternalHorizLabel
? { ...(component as any).size, width: undefined, height: undefined }
? { ...(component as any).size, width: undefined }
: (component as any).size;
const rendererProps = {
@ -797,35 +801,33 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
componentType === "modal-repeater-table" ||
componentType === "v2-input";
// 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true/"true"일 때만 라벨 표시)
// 🆕 v2-input 등의 라벨 표시 로직 (InteractiveScreenViewerDynamic과 동일한 부정형 체크)
const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
const effectiveLabel =
labelDisplay === true || labelDisplay === "true"
labelDisplay !== false && labelDisplay !== "false"
? component.style?.labelText || (component as any).label || component.componentConfig?.label
: undefined;
// 🔧 수평 라벨(left/right) 감지 → 외부 flex 컨테이너에서 라벨 처리
// 🔧 수평 라벨(left/right) 감지 → 런타임에서만 외부 flex 컨테이너로 라벨 처리
// 디자인 모드에서는 V2 컴포넌트가 자체적으로 라벨을 렌더링 (height 체인 문제 방지)
const labelPosition = component.style?.labelPosition;
const isV2Component = componentType?.startsWith("v2-");
const needsExternalHorizLabel = !!(
!props.isDesignMode &&
isV2Component &&
effectiveLabel &&
(labelPosition === "left" || labelPosition === "right")
);
// 🔧 순서 중요! component.style 먼저, CSS 크기 속성은 size 기반으로 덮어씀
const mergedStyle = {
...component.style, // 원본 style (labelDisplay, labelText 등) - 먼저!
// CSS 크기 속성은 size에서 계산한 값으로 명시적 덮어쓰기 (우선순위 최고)
...component.style,
width: finalStyle.width,
height: finalStyle.height,
// 수평 라벨 → V2 컴포넌트에는 라벨 비활성화 (외부에서 처리)
...(needsExternalHorizLabel
? {
labelDisplay: false,
labelPosition: "top" as const,
width: "100%",
height: "100%",
borderWidth: undefined,
borderColor: undefined,
borderStyle: undefined,

View File

@ -1751,7 +1751,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
<TableHeader>
<TableRow>
{displayColumns.map((col, idx) => (
<TableHead key={idx} style={{ width: col.width && col.width <= 100 ? `${col.width}%` : "auto" }}>
<TableHead key={idx} style={{ minWidth: col.width ? `${col.width}px` : "80px" }}>
{col.label || col.name}
</TableHead>
))}
@ -1952,7 +1952,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
</TableHead>
)}
{displayColumns.map((col, idx) => (
<TableHead key={idx} style={{ width: col.width && col.width <= 100 ? `${col.width}%` : "auto" }}>
<TableHead key={idx} style={{ minWidth: col.width ? `${col.width}px` : "80px" }}>
{col.label || col.name}
</TableHead>
))}

View File

@ -2,7 +2,7 @@
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { ComponentRendererProps } from "../../types";
import { SplitPanelLayoutConfig } from "./types";
import { SplitPanelLayoutConfig, MAX_LOAD_ALL_SIZE } from "./types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -16,6 +16,9 @@ import {
ChevronUp,
Save,
ChevronRight,
ChevronLeft,
ChevronsLeft,
ChevronsRight,
Pencil,
Trash2,
Settings,
@ -48,6 +51,66 @@ import { cn } from "@/lib/utils";
import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer";
import { BomExcelUploadModal } from "../v2-bom-tree/BomExcelUploadModal";
/** 클라이언트 사이드 데이터 필터 (페이징 OFF 전용) */
function applyClientSideFilter(data: any[], dataFilter: any): any[] {
if (!dataFilter?.enabled) return data;
let result = data;
if (dataFilter.filters?.length > 0) {
const matchFn = dataFilter.matchType === "any" ? "some" : "every";
result = result.filter((item: any) =>
dataFilter.filters[matchFn]((cond: any) => {
const val = item[cond.columnName];
switch (cond.operator) {
case "equals":
return val === cond.value;
case "notEquals":
case "not_equals":
return val !== cond.value;
case "in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return arr.includes(val);
}
case "not_in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return !arr.includes(val);
}
case "contains":
return String(val || "").includes(String(cond.value));
case "is_null":
return val === null || val === undefined || val === "";
case "is_not_null":
return val !== null && val !== undefined && val !== "";
default:
return true;
}
}),
);
}
// legacy conditions 형식 (하위 호환성)
if (dataFilter.conditions?.length > 0) {
result = result.filter((item: any) =>
dataFilter.conditions.every((cond: any) => {
const val = item[cond.column];
switch (cond.operator) {
case "equals":
return val === cond.value;
case "notEquals":
return val !== cond.value;
case "contains":
return String(val || "").includes(String(cond.value));
default:
return true;
}
}),
);
}
return result;
}
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
// 추가 props
onUpdateComponent?: (component: any) => void;
@ -351,6 +414,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [columnInputTypes, setColumnInputTypes] = useState<Record<string, string>>({});
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
// 🆕 페이징 상태
const [leftCurrentPage, setLeftCurrentPage] = useState(1);
const [leftTotalPages, setLeftTotalPages] = useState(1);
const [leftTotal, setLeftTotal] = useState(0);
const [leftPageSize, setLeftPageSize] = useState(componentConfig.leftPanel?.pagination?.pageSize ?? 20);
const [rightCurrentPage, setRightCurrentPage] = useState(1);
const [rightTotalPages, setRightTotalPages] = useState(1);
const [rightTotal, setRightTotal] = useState(0);
const [rightPageSize, setRightPageSize] = useState(componentConfig.rightPanel?.pagination?.pageSize ?? 20);
const [tabsPagination, setTabsPagination] = useState<Record<number, { currentPage: number; totalPages: number; total: number; pageSize: number }>>({});
const [leftPageInput, setLeftPageInput] = useState("1");
const [rightPageInput, setRightPageInput] = useState("1");
const leftPaginationEnabled = componentConfig.leftPanel?.pagination?.enabled ?? false;
const rightPaginationEnabled = componentConfig.rightPanel?.pagination?.enabled ?? false;
// 추가 탭 관련 상태
const [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭, 1+ = 추가 탭
const [tabsData, setTabsData] = useState<Record<number, any[]>>({}); // 탭별 데이터
@ -919,13 +998,24 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
let columns = displayColumns;
// columnVisibility가 있으면 가시성 적용
// columnVisibility가 있으면 가시성 + 너비 적용
if (leftColumnVisibility.length > 0) {
const visibilityMap = new Map(leftColumnVisibility.map((cv) => [cv.columnName, cv.visible]));
columns = columns.filter((col: any) => {
const colName = typeof col === "string" ? col : col.name || col.columnName;
return visibilityMap.get(colName) !== false;
});
const visibilityMap = new Map(
leftColumnVisibility.map((cv) => [cv.columnName, cv])
);
columns = columns
.filter((col: any) => {
const colName = typeof col === "string" ? col : col.name || col.columnName;
return visibilityMap.get(colName)?.visible !== false;
})
.map((col: any) => {
const colName = typeof col === "string" ? col : col.name || col.columnName;
const cv = visibilityMap.get(colName);
if (cv?.width && typeof col === "object") {
return { ...col, width: cv.width };
}
return col;
});
}
// 🔧 컬럼 순서 적용
@ -1241,87 +1331,62 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return joinColumns.length > 0 ? joinColumns : undefined;
}, []);
// 좌측 데이터 로드
const loadLeftData = useCallback(async () => {
// 좌측 데이터 로드 (페이징 ON: page 파라미터 사용, OFF: 전체 로드)
const loadLeftData = useCallback(async (page?: number, pageSizeOverride?: number) => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (!leftTableName || isDesignMode) return;
setIsLoadingLeft(true);
try {
// 🎯 필터 조건을 API에 전달 (entityJoinApi 사용)
const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined;
// 🆕 좌측 패널 config의 Entity 조인 컬럼 추출 (헬퍼 함수 사용)
const leftJoinColumns = extractAdditionalJoinColumns(
componentConfig.leftPanel?.columns,
leftTableName,
);
console.log("🔗 [분할패널] 좌측 additionalJoinColumns:", leftJoinColumns);
if (leftPaginationEnabled) {
const currentPageToLoad = page ?? leftCurrentPage;
const effectivePageSize = pageSizeOverride ?? leftPageSize;
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
page: currentPageToLoad,
size: effectivePageSize,
search: filters,
enableEntityJoin: true,
dataFilter: componentConfig.leftPanel?.dataFilter,
additionalJoinColumns: leftJoinColumns,
companyCodeOverride: companyCode,
});
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
page: 1,
size: 100,
search: filters,
enableEntityJoin: true,
dataFilter: componentConfig.leftPanel?.dataFilter,
additionalJoinColumns: leftJoinColumns,
companyCodeOverride: companyCode,
});
setLeftData(result.data || []);
setLeftCurrentPage(result.page || currentPageToLoad);
setLeftTotalPages(result.totalPages || 1);
setLeftTotal(result.total || 0);
setLeftPageInput(String(result.page || currentPageToLoad));
} else {
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
page: 1,
size: MAX_LOAD_ALL_SIZE,
search: filters,
enableEntityJoin: true,
dataFilter: componentConfig.leftPanel?.dataFilter,
additionalJoinColumns: leftJoinColumns,
companyCodeOverride: companyCode,
});
// 🔍 디버깅: API 응답 데이터의 키 확인
if (result.data && result.data.length > 0) {
console.log("🔗 [분할패널] API 응답 첫 번째 데이터 키:", Object.keys(result.data[0]));
console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]);
}
let filteredLeftData = applyClientSideFilter(result.data || [], componentConfig.leftPanel?.dataFilter);
// 좌측 패널 dataFilter 클라이언트 사이드 적용
let filteredLeftData = result.data || [];
const leftDataFilter = componentConfig.leftPanel?.dataFilter;
if (leftDataFilter?.enabled && leftDataFilter.filters?.length > 0) {
const matchFn = leftDataFilter.matchType === "any" ? "some" : "every";
filteredLeftData = filteredLeftData.filter((item: any) => {
return leftDataFilter.filters[matchFn]((cond: any) => {
const val = item[cond.columnName];
switch (cond.operator) {
case "equals":
return val === cond.value;
case "not_equals":
return val !== cond.value;
case "in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return arr.includes(val);
}
case "not_in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return !arr.includes(val);
}
case "contains":
return String(val || "").includes(String(cond.value));
case "is_null":
return val === null || val === undefined || val === "";
case "is_not_null":
return val !== null && val !== undefined && val !== "";
default:
return true;
}
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
if (leftColumn && filteredLeftData.length > 0) {
filteredLeftData.sort((a, b) => {
const aValue = String(a[leftColumn] || "");
const bValue = String(b[leftColumn] || "");
return aValue.localeCompare(bValue, "ko-KR");
});
});
}
}
// 가나다순 정렬 (좌측 패널의 표시 컬럼 기준)
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
if (leftColumn && filteredLeftData.length > 0) {
filteredLeftData.sort((a, b) => {
const aValue = String(a[leftColumn] || "");
const bValue = String(b[leftColumn] || "");
return aValue.localeCompare(bValue, "ko-KR");
});
const hierarchicalData = buildHierarchy(filteredLeftData);
setLeftData(hierarchicalData);
}
// 계층 구조 빌드
const hierarchicalData = buildHierarchy(filteredLeftData);
setLeftData(hierarchicalData);
} catch (error) {
console.error("좌측 데이터 로드 실패:", error);
toast({
@ -1337,15 +1402,25 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
componentConfig.leftPanel?.columns,
componentConfig.leftPanel?.dataFilter,
componentConfig.rightPanel?.relation?.leftColumn,
leftPaginationEnabled,
leftCurrentPage,
leftPageSize,
isDesignMode,
toast,
buildHierarchy,
searchValues,
]);
// 우측 데이터 로드 (leftItem이 null이면 전체 데이터 로드)
const updateRightPaginationState = useCallback((result: any, fallbackPage: number) => {
setRightCurrentPage(result.page || fallbackPage);
setRightTotalPages(result.totalPages || 1);
setRightTotal(result.total || 0);
setRightPageInput(String(result.page || fallbackPage));
}, []);
// 우측 데이터 로드 (leftItem이 null이면 전체 데이터 로드, page: 서버 페이징용)
const loadRightData = useCallback(
async (leftItem: any) => {
async (leftItem: any, page?: number, pageSizeOverride?: number) => {
const relationshipType = componentConfig.rightPanel?.relation?.type || "detail";
const rightTableName = componentConfig.rightPanel?.tableName;
@ -1359,70 +1434,33 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
componentConfig.rightPanel?.columns,
rightTableName,
);
const effectivePageSize = pageSizeOverride ?? rightPageSize;
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
enableEntityJoin: true,
size: 1000,
companyCodeOverride: companyCode,
additionalJoinColumns: rightJoinColumns,
dataFilter: componentConfig.rightPanel?.dataFilter,
});
// dataFilter 적용
let filteredData = result.data || [];
const dataFilter = componentConfig.rightPanel?.dataFilter;
if (dataFilter?.enabled && dataFilter.filters?.length > 0) {
filteredData = filteredData.filter((item: any) => {
return dataFilter.filters.every((cond: any) => {
const value = item[cond.columnName];
switch (cond.operator) {
case "equals":
return value === cond.value;
case "notEquals":
case "not_equals":
return value !== cond.value;
case "in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return arr.includes(value);
}
case "not_in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return !arr.includes(value);
}
case "contains":
return String(value || "").includes(String(cond.value));
case "is_null":
return value === null || value === undefined || value === "";
case "is_not_null":
return value !== null && value !== undefined && value !== "";
default:
return true;
}
});
if (rightPaginationEnabled) {
const currentPageToLoad = page ?? rightCurrentPage;
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
page: currentPageToLoad,
size: effectivePageSize,
enableEntityJoin: true,
companyCodeOverride: companyCode,
additionalJoinColumns: rightJoinColumns,
dataFilter: componentConfig.rightPanel?.dataFilter,
});
}
// conditions 형식 dataFilter도 지원 (하위 호환성)
const dataFilterConditions = componentConfig.rightPanel?.dataFilter;
if (dataFilterConditions?.enabled && dataFilterConditions.conditions?.length > 0) {
filteredData = filteredData.filter((item: any) => {
return dataFilterConditions.conditions.every((cond: any) => {
const value = item[cond.column];
switch (cond.operator) {
case "equals":
return value === cond.value;
case "notEquals":
return value !== cond.value;
case "contains":
return String(value || "").includes(String(cond.value));
default:
return true;
}
});
setRightData(result.data || []);
updateRightPaginationState(result, currentPageToLoad);
} else {
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
enableEntityJoin: true,
size: MAX_LOAD_ALL_SIZE,
companyCodeOverride: companyCode,
additionalJoinColumns: rightJoinColumns,
dataFilter: componentConfig.rightPanel?.dataFilter,
});
}
setRightData(filteredData);
const filteredData = applyClientSideFilter(result.data || [], componentConfig.rightPanel?.dataFilter);
setRightData(filteredData);
}
} catch (error) {
console.error("우측 전체 데이터 로드 실패:", error);
} finally {
@ -1499,9 +1537,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
search: searchConditions,
enableEntityJoin: true,
size: 1000,
size: MAX_LOAD_ALL_SIZE,
companyCodeOverride: companyCode,
additionalJoinColumns: rightJoinColumnsForGroup, // 🆕 Entity 조인 컬럼 전달
additionalJoinColumns: rightJoinColumnsForGroup,
});
if (result.data) {
allResults.push(...result.data);
@ -1540,16 +1578,19 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
console.log("🔗 [분할패널] 우측 패널 additionalJoinColumns:", rightJoinColumns);
}
// 엔티티 조인 API로 데이터 조회
const effectivePageSize = pageSizeOverride ?? rightPageSize;
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
search: searchConditions,
enableEntityJoin: true,
size: 1000,
size: rightPaginationEnabled ? effectivePageSize : MAX_LOAD_ALL_SIZE,
page: rightPaginationEnabled ? (page ?? rightCurrentPage) : undefined,
companyCodeOverride: companyCode,
additionalJoinColumns: rightJoinColumns,
});
console.log("🔗 [분할패널] 복합키 조회 결과:", result);
if (rightPaginationEnabled) {
updateRightPaginationState(result, page ?? rightCurrentPage);
}
setRightData(result.data || []);
} else {
@ -1576,14 +1617,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
console.log("🔗 [분할패널] 단일키 모드 additionalJoinColumns:", rightJoinColumnsLegacy);
}
const effectivePageSizeLegacy = pageSizeOverride ?? rightPageSize;
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
search: searchConditions,
enableEntityJoin: true,
size: 1000,
size: rightPaginationEnabled ? effectivePageSizeLegacy : MAX_LOAD_ALL_SIZE,
page: rightPaginationEnabled ? (page ?? rightCurrentPage) : undefined,
companyCodeOverride: companyCode,
additionalJoinColumns: rightJoinColumnsLegacy,
});
if (rightPaginationEnabled) {
updateRightPaginationState(result, page ?? rightCurrentPage);
}
setRightData(result.data || []);
}
}
@ -1604,14 +1651,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
componentConfig.rightPanel?.tableName,
componentConfig.rightPanel?.relation,
componentConfig.leftPanel?.tableName,
rightPaginationEnabled,
rightCurrentPage,
rightPageSize,
isDesignMode,
toast,
updateRightPaginationState,
],
);
// 추가 탭 데이터 로딩 함수 (leftItem이 null이면 전체 데이터 로드)
// 추가 탭 데이터 로딩 함수 (leftItem이 null이면 전체 데이터 로드, page: 서버 페이징용)
const loadTabData = useCallback(
async (tabIndex: number, leftItem: any) => {
async (tabIndex: number, leftItem: any, page?: number, pageSizeOverride?: number) => {
const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1];
if (!tabConfig || isDesignMode) return;
@ -1623,109 +1674,73 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const keys = tabConfig.relation?.keys;
const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn;
const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn;
// 탭 config의 Entity 조인 컬럼 추출
const tabJoinColumns = extractAdditionalJoinColumns(tabConfig.columns, tabTableName);
if (tabJoinColumns) {
console.log(`🔗 [분할패널] 탭 ${tabIndex} additionalJoinColumns:`, tabJoinColumns);
}
let resultData: any[] = [];
// 탭의 dataFilter (API 전달용)
let apiResult: any = null;
const tabDataFilterForApi = (tabConfig as any).dataFilter;
// 탭의 relation type 확인 (detail이면 초기 전체 로드 안 함)
const tabRelationType = tabConfig.relation?.type || "join";
const tabPagState = tabsPagination[tabIndex];
const currentTabPage = page ?? tabPagState?.currentPage ?? 1;
const currentTabPageSize = pageSizeOverride ?? tabPagState?.pageSize ?? rightPageSize;
const apiSize = rightPaginationEnabled ? currentTabPageSize : MAX_LOAD_ALL_SIZE;
const apiPage = rightPaginationEnabled ? currentTabPage : undefined;
const commonApiParams = {
enableEntityJoin: true,
size: apiSize,
page: apiPage,
companyCodeOverride: companyCode,
additionalJoinColumns: tabJoinColumns,
dataFilter: tabDataFilterForApi,
};
if (!leftItem) {
if (tabRelationType === "detail") {
// detail 모드: 선택 안 하면 아무것도 안 뜸
resultData = [];
} else {
// join 모드: 좌측 미선택 시 전체 데이터 로드 (dataFilter는 API에 전달)
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
enableEntityJoin: true,
size: 1000,
companyCodeOverride: companyCode,
additionalJoinColumns: tabJoinColumns,
dataFilter: tabDataFilterForApi,
});
resultData = result.data || [];
if (tabRelationType !== "detail") {
apiResult = await entityJoinApi.getTableDataWithJoins(tabTableName, commonApiParams);
resultData = apiResult.data || [];
}
} else if (leftColumn && rightColumn) {
const searchConditions: Record<string, any> = {};
if (keys && keys.length > 0) {
keys.forEach((key: any) => {
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
searchConditions[key.rightColumn] = {
value: leftItem[key.leftColumn],
operator: "equals",
};
searchConditions[key.rightColumn] = { value: leftItem[key.leftColumn], operator: "equals" };
}
});
} else {
const leftValue = leftItem[leftColumn];
if (leftValue !== undefined) {
searchConditions[rightColumn] = {
value: leftValue,
operator: "equals",
};
searchConditions[rightColumn] = { value: leftValue, operator: "equals" };
}
}
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
apiResult = await entityJoinApi.getTableDataWithJoins(tabTableName, {
search: searchConditions,
enableEntityJoin: true,
size: 1000,
companyCodeOverride: companyCode,
additionalJoinColumns: tabJoinColumns,
dataFilter: tabDataFilterForApi,
...commonApiParams,
});
resultData = result.data || [];
resultData = apiResult.data || [];
} else {
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
enableEntityJoin: true,
size: 1000,
companyCodeOverride: companyCode,
additionalJoinColumns: tabJoinColumns,
dataFilter: tabDataFilterForApi,
});
resultData = result.data || [];
apiResult = await entityJoinApi.getTableDataWithJoins(tabTableName, commonApiParams);
resultData = apiResult.data || [];
}
// 탭별 dataFilter 적용
const tabDataFilter = (tabConfig as any).dataFilter;
if (tabDataFilter?.enabled && tabDataFilter.filters?.length > 0) {
resultData = resultData.filter((item: any) => {
return tabDataFilter.filters.every((cond: any) => {
const value = item[cond.columnName];
switch (cond.operator) {
case "equals":
return value === cond.value;
case "notEquals":
case "not_equals":
return value !== cond.value;
case "in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return arr.includes(value);
}
case "not_in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return !arr.includes(value);
}
case "contains":
return String(value || "").includes(String(cond.value));
case "is_null":
return value === null || value === undefined || value === "";
case "is_not_null":
return value !== null && value !== undefined && value !== "";
default:
return true;
}
});
});
// 공통 페이징 상태 업데이트
if (rightPaginationEnabled && apiResult) {
setTabsPagination((prev) => ({
...prev,
[tabIndex]: {
currentPage: apiResult.page || currentTabPage,
totalPages: apiResult.totalPages || 1,
total: apiResult.total || 0,
pageSize: currentTabPageSize,
},
}));
}
if (!rightPaginationEnabled) {
resultData = applyClientSideFilter(resultData, (tabConfig as any).dataFilter);
}
setTabsData((prev) => ({ ...prev, [tabIndex]: resultData }));
@ -1740,9 +1755,148 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
setTabsLoading((prev) => ({ ...prev, [tabIndex]: false }));
}
},
[componentConfig.rightPanel?.additionalTabs, isDesignMode, toast],
[componentConfig.rightPanel?.additionalTabs, rightPaginationEnabled, rightPageSize, tabsPagination, isDesignMode, toast],
);
// 🆕 좌측 페이지 변경 핸들러
const handleLeftPageChange = useCallback((newPage: number) => {
if (newPage < 1 || newPage > leftTotalPages) return;
setLeftCurrentPage(newPage);
setLeftPageInput(String(newPage));
loadLeftData(newPage);
}, [leftTotalPages, loadLeftData]);
const commitLeftPageInput = useCallback(() => {
const parsed = parseInt(leftPageInput, 10);
if (!isNaN(parsed) && parsed >= 1 && parsed <= leftTotalPages) {
handleLeftPageChange(parsed);
} else {
setLeftPageInput(String(leftCurrentPage));
}
}, [leftPageInput, leftTotalPages, leftCurrentPage, handleLeftPageChange]);
// 🆕 좌측 페이지 크기 변경
const handleLeftPageSizeChange = useCallback((newSize: number) => {
setLeftPageSize(newSize);
setLeftCurrentPage(1);
setLeftPageInput("1");
loadLeftData(1, newSize);
}, [loadLeftData]);
// 🆕 우측 페이지 변경 핸들러
const handleRightPageChange = useCallback((newPage: number) => {
if (newPage < 1 || newPage > rightTotalPages) return;
setRightCurrentPage(newPage);
setRightPageInput(String(newPage));
if (activeTabIndex === 0) {
loadRightData(selectedLeftItem, newPage);
} else {
loadTabData(activeTabIndex, selectedLeftItem, newPage);
}
}, [rightTotalPages, activeTabIndex, selectedLeftItem, loadRightData, loadTabData]);
const commitRightPageInput = useCallback(() => {
const parsed = parseInt(rightPageInput, 10);
const tp = activeTabIndex === 0 ? rightTotalPages : (tabsPagination[activeTabIndex]?.totalPages ?? 1);
if (!isNaN(parsed) && parsed >= 1 && parsed <= tp) {
handleRightPageChange(parsed);
} else {
const cp = activeTabIndex === 0 ? rightCurrentPage : (tabsPagination[activeTabIndex]?.currentPage ?? 1);
setRightPageInput(String(cp));
}
}, [rightPageInput, rightTotalPages, rightCurrentPage, activeTabIndex, tabsPagination, handleRightPageChange]);
// 🆕 우측 페이지 크기 변경
const handleRightPageSizeChange = useCallback((newSize: number) => {
setRightPageSize(newSize);
setRightCurrentPage(1);
setRightPageInput("1");
setTabsPagination({});
if (activeTabIndex === 0) {
loadRightData(selectedLeftItem, 1, newSize);
} else {
loadTabData(activeTabIndex, selectedLeftItem, 1, newSize);
}
}, [activeTabIndex, selectedLeftItem, loadRightData, loadTabData]);
// 🆕 페이징 UI 컴포넌트 (공통)
const renderPaginationBar = useCallback((params: {
currentPage: number;
totalPages: number;
total: number;
pageSize: number;
pageInput: string;
setPageInput: (v: string) => void;
onPageChange: (p: number) => void;
onPageSizeChange: (s: number) => void;
commitPageInput: () => void;
loading: boolean;
}) => {
const { currentPage, totalPages, total, pageSize, pageInput, setPageInput, onPageChange, onPageSizeChange, commitPageInput: commitFn, loading } = params;
return (
<div className="border-border bg-background relative flex h-10 w-full flex-shrink-0 items-center justify-center border-t px-2">
<div className="absolute left-2 flex items-center gap-1">
<span className="text-muted-foreground text-[10px]">:</span>
<input
type="number"
min={1}
max={MAX_LOAD_ALL_SIZE}
value={pageSize}
onChange={(e) => {
const v = Math.min(MAX_LOAD_ALL_SIZE, Math.max(1, Number(e.target.value) || 1));
onPageSizeChange(v);
}}
className="border-input bg-background focus:ring-ring h-6 w-12 rounded border px-1 text-center text-[10px] focus:ring-1 focus:outline-none"
/>
<span className="text-muted-foreground text-[10px]">/ {total}</span>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={() => onPageChange(1)} disabled={currentPage === 1 || loading} className="h-6 w-6 p-0">
<ChevronsLeft className="h-3 w-3" />
</Button>
<Button variant="outline" size="sm" onClick={() => onPageChange(currentPage - 1)} disabled={currentPage === 1 || loading} className="h-6 w-6 p-0">
<ChevronLeft className="h-3 w-3" />
</Button>
<div className="flex items-center gap-1">
<input
type="text"
inputMode="numeric"
value={pageInput}
onChange={(e) => setPageInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { commitFn(); (e.target as HTMLInputElement).blur(); } }}
onBlur={commitFn}
onFocus={(e) => e.target.select()}
disabled={loading}
className="border-input bg-background focus:ring-ring h-6 w-8 rounded border px-1 text-center text-[10px] font-medium focus:ring-1 focus:outline-none"
/>
<span className="text-muted-foreground text-[10px]">/</span>
<span className="text-foreground text-[10px] font-medium">{totalPages || 1}</span>
</div>
<Button variant="outline" size="sm" onClick={() => onPageChange(currentPage + 1)} disabled={currentPage >= totalPages || loading} className="h-6 w-6 p-0">
<ChevronRight className="h-3 w-3" />
</Button>
<Button variant="outline" size="sm" onClick={() => onPageChange(totalPages)} disabled={currentPage >= totalPages || loading} className="h-6 w-6 p-0">
<ChevronsRight className="h-3 w-3" />
</Button>
</div>
</div>
);
}, []);
// 우측/탭 페이징 상태 (IIFE 대신 useMemo로 사전 계산)
const rightPagState = useMemo(() => {
const isTab = activeTabIndex > 0;
const tabPag = isTab ? tabsPagination[activeTabIndex] : null;
return {
isTab,
currentPage: isTab ? (tabPag?.currentPage ?? 1) : rightCurrentPage,
totalPages: isTab ? (tabPag?.totalPages ?? 1) : rightTotalPages,
total: isTab ? (tabPag?.total ?? 0) : rightTotal,
pageSize: isTab ? (tabPag?.pageSize ?? rightPageSize) : rightPageSize,
};
}, [activeTabIndex, tabsPagination, rightCurrentPage, rightTotalPages, rightTotal, rightPageSize]);
// 탭 변경 핸들러
const handleTabChange = useCallback(
(newTabIndex: number) => {
@ -1779,12 +1933,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
selectedLeftItem[leftPk] === item[leftPk];
if (isSameItem) {
// 선택 해제
setSelectedLeftItem(null);
setCustomLeftSelectedData({});
setExpandedRightItems(new Set());
setTabsData({});
// 우측/탭 페이지 리셋
if (rightPaginationEnabled) {
setRightCurrentPage(1);
setRightPageInput("1");
setTabsPagination({});
}
const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail";
if (mainRelationType === "detail") {
// "선택 시 표시" 모드: 선택 해제 시 데이터 비움
@ -1809,15 +1969,21 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}
setSelectedLeftItem(item);
setCustomLeftSelectedData(item); // 커스텀 모드 우측 폼에 선택된 데이터 전달
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
setTabsData({}); // 모든 탭 데이터 초기화
setCustomLeftSelectedData(item);
setExpandedRightItems(new Set());
setTabsData({});
// 우측/탭 페이지 리셋
if (rightPaginationEnabled) {
setRightCurrentPage(1);
setRightPageInput("1");
setTabsPagination({});
}
// 현재 활성 탭에 따라 데이터 로드
if (activeTabIndex === 0) {
loadRightData(item);
loadRightData(item, 1);
} else {
loadTabData(activeTabIndex, item);
loadTabData(activeTabIndex, item, 1);
}
// modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
@ -1829,7 +1995,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
});
}
},
[loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation, componentConfig.rightPanel?.additionalTabs, isDesignMode, selectedLeftItem],
[loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation, componentConfig.rightPanel?.additionalTabs, isDesignMode, selectedLeftItem, rightPaginationEnabled],
);
// 우측 항목 확장/축소 토글
@ -3104,10 +3270,30 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDesignMode, componentConfig.autoLoad]);
// 🔄 필터 변경 시 데이터 다시 로드
// config에서 pageSize 변경 시 상태 동기화 + 페이지 리셋
useEffect(() => {
const configLeftPageSize = componentConfig.leftPanel?.pagination?.pageSize ?? 20;
setLeftPageSize(configLeftPageSize);
setLeftCurrentPage(1);
setLeftPageInput("1");
}, [componentConfig.leftPanel?.pagination?.pageSize]);
useEffect(() => {
const configRightPageSize = componentConfig.rightPanel?.pagination?.pageSize ?? 20;
setRightPageSize(configRightPageSize);
setRightCurrentPage(1);
setRightPageInput("1");
setTabsPagination({});
}, [componentConfig.rightPanel?.pagination?.pageSize]);
// 🔄 필터 변경 시 데이터 다시 로드 (페이지 1로 리셋)
useEffect(() => {
if (!isDesignMode && componentConfig.autoLoad !== false) {
loadLeftData();
if (leftPaginationEnabled) {
setLeftCurrentPage(1);
setLeftPageInput("1");
}
loadLeftData(1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leftFilters]);
@ -3547,12 +3733,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
format: undefined, // 🆕 기본값
}));
// 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤)
const leftTotalColWidth = columnsToShow.reduce((sum, col) => {
const w = col.width && col.width <= 100 ? col.width : 0;
return sum + w;
}, 0);
// 🔧 그룹화된 데이터 렌더링
const hasGroupedLeftActions = !isDesignMode && (
(componentConfig.leftPanel?.showEdit !== false) ||
@ -3566,7 +3746,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<div className="bg-muted px-3 py-2 text-sm font-semibold">
{group.groupKey} ({group.count})
</div>
<table className="divide-y divide-border table-fixed" style={{ width: leftTotalColWidth > 100 ? `${leftTotalColWidth}%` : '100%' }}>
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted">
<tr>
{columnsToShow.map((col, idx) => (
@ -3574,7 +3754,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
key={idx}
className="px-3 py-2 text-left text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap"
style={{
width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
minWidth: col.width ? `${col.width}px` : "80px",
textAlign: col.align || "left",
}}
>
@ -3663,7 +3843,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
);
return (
<div className="overflow-auto">
<table className="divide-y divide-border table-fixed" style={{ width: leftTotalColWidth > 100 ? `${leftTotalColWidth}%` : '100%' }}>
<table className="min-w-full divide-y divide-border">
<thead className="sticky top-0 z-10 bg-muted">
<tr>
{columnsToShow.map((col, idx) => (
@ -3671,7 +3851,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
key={idx}
className="px-3 py-2 text-left text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap"
style={{
width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
minWidth: col.width ? `${col.width}px` : "80px",
textAlign: col.align || "left",
}}
>
@ -4008,6 +4188,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
)}
</CardContent>
{/* 좌측 페이징 UI */}
{leftPaginationEnabled && !isDesignMode && (
renderPaginationBar({
currentPage: leftCurrentPage,
totalPages: leftTotalPages,
total: leftTotal,
pageSize: leftPageSize,
pageInput: leftPageInput,
setPageInput: setLeftPageInput,
onPageChange: handleLeftPageChange,
onPageSizeChange: handleLeftPageSizeChange,
commitPageInput: commitLeftPageInput,
loading: isLoadingLeft,
})
)}
</Card>
</div>
@ -4666,16 +4862,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}));
}
// 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤)
const rightTotalColWidth = columnsToShow.reduce((sum, col) => {
const w = col.width && col.width <= 100 ? col.width : 0;
return sum + w;
}, 0);
return (
<div className="flex h-full w-full flex-col">
<div className="min-h-0 flex-1 overflow-auto">
<table className="table-fixed" style={{ width: rightTotalColWidth > 100 ? `${rightTotalColWidth}%` : '100%' }}>
<table className="min-w-full">
<thead className="sticky top-0 z-10">
<tr className="border-b-2 border-border/60">
{columnsToShow.map((col, idx) => (
@ -4683,7 +4873,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
key={idx}
className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap"
style={{
width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
minWidth: col.width ? `${col.width}px` : "80px",
textAlign: col.align || "left",
}}
>
@ -4796,12 +4986,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}));
}
// 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤)
const displayTotalColWidth = columnsToDisplay.reduce((sum, col) => {
const w = col.width && col.width <= 100 ? col.width : 0;
return sum + w;
}, 0);
const hasEditButton = !isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true);
const hasDeleteButton = !isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true);
const hasActions = hasEditButton || hasDeleteButton;
@ -4809,14 +4993,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return filteredData.length > 0 ? (
<div className="flex h-full w-full flex-col">
<div className="min-h-0 flex-1 overflow-auto">
<table className="table-fixed text-sm" style={{ width: displayTotalColWidth > 100 ? `${displayTotalColWidth}%` : '100%' }}>
<table className="min-w-full text-sm">
<thead className="sticky top-0 z-10 bg-background">
<tr className="border-b-2 border-border/60">
{columnsToDisplay.map((col) => (
<th
key={col.name}
className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap"
style={{ width: col.width && col.width <= 100 ? `${col.width}%` : "auto" }}
style={{ minWidth: col.width ? `${col.width}px` : "80px" }}
>
{col.label}
</th>
@ -5040,6 +5224,31 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
)}
</CardContent>
{/* 우측/탭 페이징 UI */}
{rightPaginationEnabled && !isDesignMode && renderPaginationBar({
currentPage: rightPagState.currentPage,
totalPages: rightPagState.totalPages,
total: rightPagState.total,
pageSize: rightPagState.pageSize,
pageInput: rightPageInput,
setPageInput: setRightPageInput,
onPageChange: (p) => {
if (rightPagState.isTab) {
setTabsPagination((prev) => ({
...prev,
[activeTabIndex]: { ...(prev[activeTabIndex] || { currentPage: 1, totalPages: 1, total: 0, pageSize: rightPageSize }), currentPage: p },
}));
setRightPageInput(String(p));
loadTabData(activeTabIndex, selectedLeftItem, p);
} else {
handleRightPageChange(p);
}
},
onPageSizeChange: handleRightPageSizeChange,
commitPageInput: commitRightPageInput,
loading: isLoadingRight || (tabsLoading[activeTabIndex] ?? false),
})}
</Card>
</div>

View File

@ -10,6 +10,15 @@ import { DataFilterConfig, TabInlineComponent } from "@/types/screen-management"
*/
export type PanelInlineComponent = TabInlineComponent;
/** 페이징 처리 설정 (좌측/우측 패널 공통) */
export interface PaginationConfig {
enabled: boolean;
pageSize?: number;
}
/** 페이징 OFF 시 전체 데이터 로드에 사용하는 최대 건수 */
export const MAX_LOAD_ALL_SIZE = 10000;
/**
* ( + tabId, label)
*/
@ -224,6 +233,8 @@ export interface SplitPanelLayoutConfig {
// 🆕 컬럼 값 기반 데이터 필터링
dataFilter?: DataFilterConfig;
pagination?: PaginationConfig;
};
// 우측 패널 설정
@ -351,6 +362,8 @@ export interface SplitPanelLayoutConfig {
// 🆕 추가 탭 설정 (멀티 테이블 탭)
additionalTabs?: AdditionalTabConfig[];
pagination?: PaginationConfig;
};
// 레이아웃 설정

View File

@ -798,6 +798,25 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
minHeight: "48px",
}}
>
{/* 테이블 선택 드롭다운 (여러 테이블 + showTableSelector 활성 시) */}
{showTableSelector && hasMultipleTables && (
<Select
value={selectedTableId || ""}
onValueChange={(value) => setSelectedTableId(value)}
>
<SelectTrigger className="h-9 w-full shrink-0 text-xs sm:w-[200px] sm:text-sm">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tableList.map((table) => (
<SelectItem key={table.tableId} value={table.tableId}>
{table.label || table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* 필터 입력 필드들 */}
{activeFilters.length > 0 && (
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">

View File

@ -6566,7 +6566,36 @@ export class ButtonActionExecutor {
}
// dataTransfer 설정이 있는 경우
const { targetType, targetComponentId, targetScreenId, mappingRules, receiveMode } = dataTransfer;
const { targetType, targetComponentId, targetScreenId, receiveMode } = dataTransfer;
// multiTableMappings 우선: 소스 테이블에 맞는 매핑 규칙 선택
let mappingRules = dataTransfer.mappingRules;
const multiTableMappings = (dataTransfer as any).multiTableMappings as Array<{
sourceTable: string;
mappingRules: Array<{ sourceField: string; targetField: string }>;
}> | undefined;
if (multiTableMappings && multiTableMappings.length > 0) {
const sourceTableName = context.tableName || (dataTransfer as any).sourceTable;
const matchedGroup = multiTableMappings.find((g) => g.sourceTable === sourceTableName);
if (matchedGroup && matchedGroup.mappingRules?.length > 0) {
mappingRules = matchedGroup.mappingRules;
console.log("📋 [transferData] multiTableMappings 매핑 적용:", {
sourceTable: sourceTableName,
rules: matchedGroup.mappingRules,
});
} else if (!mappingRules || mappingRules.length === 0) {
// 매칭되는 그룹이 없고 기존 mappingRules도 없으면 첫 번째 그룹 사용
const fallback = multiTableMappings[0];
if (fallback?.mappingRules?.length > 0) {
mappingRules = fallback.mappingRules;
console.log("📋 [transferData] multiTableMappings 폴백 매핑 적용:", {
sourceTable: fallback.sourceTable,
rules: fallback.mappingRules,
});
}
}
}
if (targetType === "component" && targetComponentId) {
// 같은 화면 내 컴포넌트로 전달 + 레이어 활성화 이벤트 병행

View File

@ -266,6 +266,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -307,6 +308,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -340,6 +342,7 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@ -3055,6 +3058,7 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/react-reconciler": "^0.32.0",
@ -3708,6 +3712,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.6"
},
@ -3802,6 +3807,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@ -4115,6 +4121,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
@ -6615,6 +6622,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -6625,6 +6633,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -6667,6 +6676,7 @@
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
@ -6749,6 +6759,7 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@ -7381,6 +7392,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -8531,7 +8543,8 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/d3": {
"version": "7.9.0",
@ -8853,6 +8866,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -9612,6 +9626,7 @@
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -9700,6 +9715,7 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@ -9801,6 +9817,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -10972,6 +10989,7 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@ -11752,7 +11770,8 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
"license": "BSD-2-Clause",
"peer": true
},
"node_modules/levn": {
"version": "0.4.1",
@ -13091,6 +13110,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@ -13384,6 +13404,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
@ -13413,6 +13434,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
@ -13461,6 +13483,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@ -13664,6 +13687,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -13733,6 +13757,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@ -13783,6 +13808,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@ -13815,7 +13841,8 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/react-leaflet": {
"version": "5.0.0",
@ -14123,6 +14150,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@ -14145,7 +14173,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/recharts/node_modules/redux-thunk": {
"version": "3.1.0",
@ -15175,7 +15204,8 @@
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/three-mesh-bvh": {
"version": "0.8.3",
@ -15263,6 +15293,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -15611,6 +15642,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"