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 []; 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(); export const entityJoinService = new EntityJoinService();

View File

@ -235,6 +235,312 @@ class NumberingRuleService {
); );
return result.rows[0].current_sequence; 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 hasManualPart = rule.parts.some((p: any) => p.generationMethod === "manual");
const skipSequenceLookup = hasManualPart && !manualInputValue; const skipSequenceLookup = hasManualPart && !manualInputValue;
// prefix_key 기반 순번 조회 + 테이블 내 최대값과 비교
const manualValues = manualInputValue ? [manualInputValue] : undefined; const manualValues = manualInputValue ? [manualInputValue] : undefined;
const prefixKey = await this.buildPrefixKey(rule, formData, manualValues); const prefixKey = await this.buildPrefixKey(rule, formData, manualValues);
const pool = getPool(); const pool = getPool();
@ -1108,8 +1415,36 @@ class NumberingRuleService {
? 0 ? 0
: await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey); : 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 const parts = await Promise.all(rule.parts
@ -1125,7 +1460,7 @@ class NumberingRuleService {
case "sequence": { case "sequence": {
const length = autoConfig.sequenceLength || 3; const length = autoConfig.sequenceLength || 3;
const startFrom = autoConfig.startFrom || 1; const startFrom = autoConfig.startFrom || 1;
const nextSequence = currentSeq + startFrom; const nextSequence = baseSeq + startFrom;
return String(nextSequence).padStart(length, "0"); return String(nextSequence).padStart(length, "0");
} }
@ -1239,20 +1574,15 @@ class NumberingRuleService {
const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues); const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues);
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence"); const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
// 3단계: 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득 // 3단계: 순번이 있으면 prefix_key 기반 UPSERT + 테이블 내 최대값 비교하여 다음 순번 결정
let allocatedSequence = 0; let allocatedSequence = 0;
if (hasSequence) { if (hasSequence) {
allocatedSequence = await this.incrementSequenceForPrefix( allocatedSequence = await this.resolveNextSequence(
client, ruleId, companyCode, prefixKey client, rule, companyCode, ruleId, prefixKey, formData
);
// 호환성을 위해 기존 current_sequence도 업데이트
await client.query(
"UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2",
[ruleId, companyCode]
); );
} }
logger.info("allocateCode: prefix_key 기반 순번 할당", { logger.info("allocateCode: prefix_key + 테이블 기반 순번 할당", {
ruleId, prefixKey, allocatedSequence, extractedManualValues, ruleId, prefixKey, allocatedSequence, extractedManualValues,
}); });

View File

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

View File

@ -12,7 +12,7 @@ import {
Search, Search,
Database, Database,
RefreshCw, RefreshCw,
Settings, Save,
Plus, Plus,
Activity, Activity,
Trash2, Trash2,
@ -21,7 +21,6 @@ import {
ChevronsUpDown, ChevronsUpDown,
Loader2, Loader2,
} from "lucide-react"; } from "lucide-react";
import { ResponsiveSplitPanel } from "@/components/common/ResponsiveSplitPanel";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { toast } from "sonner"; import { toast } from "sonner";
@ -50,43 +49,10 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import type { TableInfo, ColumnTypeInfo, SecondLevelMenu } from "@/components/admin/table-type/types";
interface TableInfo { import { TypeOverviewStrip } from "@/components/admin/table-type/TypeOverviewStrip";
tableName: string; import { ColumnGrid } from "@/components/admin/table-type/ColumnGrid";
displayName: string; import { ColumnDetailPanel } from "@/components/admin/table-type/ColumnDetailPanel";
description: string;
columnCount: number;
}
interface ColumnTypeInfo {
columnName: string;
displayName: string;
inputType: string; // webType → inputType 변경
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; // 🎯 Entity 조인에서 표시할 컬럼명
categoryMenus?: number[];
hierarchyRole?: "large" | "medium" | "small";
numberingRuleId?: string;
categoryRef?: string | null;
}
interface SecondLevelMenu {
menuObjid: number;
menuName: string;
parentMenuName: string;
screenCode?: string;
}
export default function TableManagementPage() { export default function TableManagementPage() {
const { userLang, getText } = useMultiLang({ companyCode: "*" }); const { userLang, getText } = useMultiLang({ companyCode: "*" });
@ -164,6 +130,11 @@ export default function TableManagementPage() {
// 선택된 테이블 목록 (체크박스) // 선택된 테이블 목록 (체크박스)
const [selectedTableIds, setSelectedTableIds] = useState<Set<string>>(new Set()); const [selectedTableIds, setSelectedTableIds] = useState<Set<string>>(new Set());
// 컬럼 그리드: 선택된 컬럼(우측 상세 패널 표시)
const [selectedColumn, setSelectedColumn] = useState<string | null>(null);
// 타입 오버뷰 스트립: 타입 필터 (null = 전체)
const [typeFilter, setTypeFilter] = useState<string | null>(null);
// 최고 관리자 여부 확인 (회사코드가 "*" AND userType이 "SUPER_ADMIN") // 최고 관리자 여부 확인 (회사코드가 "*" AND userType이 "SUPER_ADMIN")
const isSuperAdmin = user?.companyCode === "*" && user?.userType === "SUPER_ADMIN"; const isSuperAdmin = user?.companyCode === "*" && user?.userType === "SUPER_ADMIN";
@ -442,6 +413,8 @@ export default function TableManagementPage() {
setSelectedTable(tableName); setSelectedTable(tableName);
setCurrentPage(1); setCurrentPage(1);
setColumns([]); setColumns([]);
setSelectedColumn(null);
setTypeFilter(null);
// 선택된 테이블 정보에서 라벨 설정 // 선택된 테이블 정보에서 라벨 설정
const tableInfo = tables.find((table) => table.tableName === tableName); const tableInfo = tables.find((table) => table.tableName === tableName);
@ -995,16 +968,24 @@ export default function TableManagementPage() {
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedTable, columns.length]); }, [selectedTable, columns.length]);
// 필터링된 테이블 목록 (메모이제이션) // 필터링 + 한글 우선 정렬 (ㄱ~ㅎ → a~z)
const filteredTables = useMemo( const filteredTables = useMemo(() => {
() => const filtered = tables.filter(
tables.filter(
(table) => (table) =>
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
table.displayName.toLowerCase().includes(searchTerm.toLowerCase()), table.displayName.toLowerCase().includes(searchTerm.toLowerCase()),
),
[tables, searchTerm],
); );
const isKorean = (str: string) => /^[가-힣ㄱ-ㅎ]/.test(str);
return filtered.sort((a, b) => {
const nameA = a.displayName || a.tableName;
const nameB = b.displayName || b.tableName;
const aKo = isKorean(nameA);
const bKo = isKorean(nameB);
if (aKo && !bKo) return -1;
if (!aKo && bKo) return 1;
return nameA.localeCompare(nameB, aKo ? "ko" : "en");
});
}, [tables, searchTerm]);
// 선택된 테이블 정보 // 선택된 테이블 정보
const selectedTableInfo = tables.find((table) => table.tableName === selectedTable); const selectedTableInfo = tables.find((table) => table.tableName === selectedTable);
@ -1318,30 +1299,19 @@ export default function TableManagementPage() {
}; };
return ( return (
<div className="bg-background flex h-screen flex-col"> <div className="bg-background flex h-screen flex-col overflow-hidden">
<div className="flex h-full flex-col space-y-6 overflow-hidden p-6"> {/* 컴팩트 탑바 (52px) */}
{/* 페이지 헤더 */} <div className="flex h-[52px] flex-shrink-0 items-center justify-between border-b px-5">
<div className="flex-shrink-0 space-y-2 border-b pb-4"> <div className="flex items-center gap-3">
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between"> <Database className="h-4.5 w-4.5 text-muted-foreground" />
<div> <h1 className="text-[15px] font-bold tracking-tight">
<h1 className="text-3xl font-bold tracking-tight">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")} {getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}
</h1> </h1>
<p className="text-muted-foreground mt-2 text-sm"> <Badge variant="secondary" className="text-[10px] font-bold">
{getTextFromUI( {tables.length}
TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION, </Badge>
"데이터베이스 테이블과 컬럼의 타입을 관리합니다",
)}
</p>
{isSuperAdmin && (
<p className="text-primary mt-1 text-sm font-medium">
</p>
)}
</div> </div>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-2">
{/* DDL 기능 버튼들 (최고 관리자만) */}
{isSuperAdmin && ( {isSuperAdmin && (
<> <>
<Button <Button
@ -1350,12 +1320,12 @@ export default function TableManagementPage() {
setDuplicateSourceTable(null); setDuplicateSourceTable(null);
setCreateTableModalOpen(true); setCreateTableModalOpen(true);
}} }}
className="h-10 gap-2 text-sm font-medium" size="sm"
size="default" className="h-8 gap-1.5 text-xs"
> >
<Plus className="h-4 w-4" /> <Plus className="h-3.5 w-3.5" />
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
if (selectedTableIds.size !== 1) { if (selectedTableIds.size !== 1) {
@ -1368,91 +1338,76 @@ export default function TableManagementPage() {
setCreateTableModalOpen(true); setCreateTableModalOpen(true);
}} }}
variant="outline" variant="outline"
size="sm"
disabled={selectedTableIds.size !== 1} disabled={selectedTableIds.size !== 1}
className="h-10 gap-2 text-sm font-medium" className="h-8 gap-1.5 text-xs"
> >
<Copy className="h-4 w-4" /> <Copy className="h-3.5 w-3.5" />
</Button> </Button>
{selectedTable && ( {selectedTable && (
<Button <Button
onClick={() => setAddColumnModalOpen(true)} onClick={() => setAddColumnModalOpen(true)}
variant="outline" variant="outline"
className="h-10 gap-2 text-sm font-medium" size="sm"
className="h-8 gap-1.5 text-xs"
> >
<Plus className="h-4 w-4" /> <Plus className="h-3.5 w-3.5" />
</Button> </Button>
)} )}
<Button <Button
onClick={() => setDdlLogViewerOpen(true)} onClick={() => setDdlLogViewerOpen(true)}
variant="outline" variant="ghost"
className="h-10 gap-2 text-sm font-medium" size="sm"
className="h-8 gap-1.5 text-xs"
> >
<Activity className="h-4 w-4" /> <Activity className="h-3.5 w-3.5" />
DDL DDL
</Button> </Button>
</> </>
)} )}
<Button <Button
onClick={loadTables} onClick={loadTables}
disabled={loading} disabled={loading}
variant="outline" variant="ghost"
className="h-10 gap-2 text-sm font-medium" size="sm"
className="h-8 gap-1.5 text-xs"
> >
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /> <RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
{getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_REFRESH, "새로고침")}
</Button> </Button>
</div> </div>
</div> </div>
</div>
<ResponsiveSplitPanel {/* 3패널 메인 */}
left={ <div className="flex flex-1 overflow-hidden">
<div className="flex h-full flex-col space-y-4"> {/* 좌측: 테이블 목록 (240px) */}
<div className="bg-card flex w-[240px] min-w-[240px] flex-shrink-0 flex-col border-r">
{/* 검색 */} {/* 검색 */}
<div className="flex-shrink-0"> <div className="flex-shrink-0 p-3 pb-0">
<div className="relative"> <div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" /> <Search className="text-muted-foreground absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2" />
<Input <Input
placeholder={getTextFromUI(TABLE_MANAGEMENT_KEYS.SEARCH_PLACEHOLDER, "테이블 검색...")} placeholder={getTextFromUI(TABLE_MANAGEMENT_KEYS.SEARCH_PLACEHOLDER, "테이블 검색...")}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm" className="bg-background h-[34px] pl-8 text-xs"
/> />
</div> </div>
</div>
{/* 테이블 목록 */}
<div className="flex-1 space-y-3 overflow-y-auto">
{/* 전체 선택 및 일괄 삭제 (최고 관리자만) */}
{isSuperAdmin && ( {isSuperAdmin && (
<div className="flex items-center justify-between border-b pb-3"> <div className="mt-2 flex items-center justify-between border-b pb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<Checkbox <Checkbox
checked={ checked={
tables.filter( filteredTables.length > 0 &&
(table) => filteredTables.every((table) => selectedTableIds.has(table.tableName))
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
(table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())),
).length > 0 &&
tables
.filter(
(table) =>
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
(table.displayName &&
table.displayName.toLowerCase().includes(searchTerm.toLowerCase())),
)
.every((table) => selectedTableIds.has(table.tableName))
} }
onCheckedChange={handleSelectAll} onCheckedChange={handleSelectAll}
aria-label="전체 선택" aria-label="전체 선택"
className="h-3.5 w-3.5"
/> />
<span className="text-muted-foreground text-sm"> <span className="text-muted-foreground text-[10px]">
{selectedTableIds.size > 0 && `${selectedTableIds.size} 선택됨`} {selectedTableIds.size > 0 ? `${selectedTableIds.size}` : "전체"}
</span> </span>
</div> </div>
{selectedTableIds.size > 0 && ( {selectedTableIds.size > 0 && (
@ -1460,7 +1415,7 @@ export default function TableManagementPage() {
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={handleBulkDeleteClick} onClick={handleBulkDeleteClick}
className="h-8 gap-2 text-xs" className="h-6 gap-1 px-2 text-[10px]"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
@ -1468,551 +1423,219 @@ export default function TableManagementPage() {
)} )}
</div> </div>
)} )}
</div>
{/* 테이블 리스트 */}
<div className="flex-1 overflow-y-auto px-1">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<LoadingSpinner /> <LoadingSpinner />
<span className="text-muted-foreground ml-2 text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_TABLES, "테이블 로딩 중...")}
</span>
</div> </div>
) : tables.length === 0 ? ( ) : filteredTables.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm"> <div className="text-muted-foreground py-8 text-center text-xs">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")} {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")}
</div> </div>
) : ( ) : (
tables filteredTables.map((table, idx) => {
.filter( const isActive = selectedTable === table.tableName;
(table) => const prevTable = idx > 0 ? filteredTables[idx - 1] : null;
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || const isKo = /^[가-힣ㄱ-ㅎ]/.test(table.displayName || table.tableName);
(table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())), const prevIsKo = prevTable ? /^[가-힣ㄱ-ㅎ]/.test(prevTable.displayName || prevTable.tableName) : null;
) const showDivider = idx === 0 || (prevIsKo !== null && isKo !== prevIsKo);
.map((table) => (
return (
<div key={table.tableName}>
{showDivider && (
<div className="text-muted-foreground/60 mt-2 mb-1 px-2 text-[9px] font-bold uppercase tracking-widest">
{isKo ? "한글" : "ENGLISH"}
</div>
)}
<div <div
key={table.tableName} className={cn(
className={`bg-card rounded-lg p-4 shadow-sm transition-all ${ "group relative flex items-center gap-2 rounded-md px-2.5 py-[7px] transition-colors",
selectedTable === table.tableName isActive
? "bg-muted/30 shadow-md" ? "bg-accent text-foreground"
: "hover:bg-muted/20 hover:shadow-lg" : "text-foreground/80 hover:bg-accent/50",
}`} )}
style={ onClick={() => handleTableSelect(table.tableName)}
selectedTable === table.tableName role="button"
? { border: "2px solid #000000" } tabIndex={0}
: { border: "2px solid transparent" } onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleTableSelect(table.tableName);
} }
}}
> >
<div className="flex items-start gap-3"> {isActive && (
{/* 체크박스 (최고 관리자만) */} <div className="bg-primary absolute top-1.5 bottom-1.5 left-0 w-[3px] rounded-r" />
)}
{isSuperAdmin && ( {isSuperAdmin && (
<Checkbox <Checkbox
checked={selectedTableIds.has(table.tableName)} checked={selectedTableIds.has(table.tableName)}
onCheckedChange={(checked) => handleTableCheck(table.tableName, checked as boolean)} onCheckedChange={(checked) => handleTableCheck(table.tableName, checked as boolean)}
aria-label={`${table.displayName || table.tableName} 선택`} aria-label={`${table.displayName || table.tableName} 선택`}
className="mt-0.5" className="h-3.5 w-3.5 flex-shrink-0"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
)} )}
<div className="flex-1 cursor-pointer" onClick={() => handleTableSelect(table.tableName)}> <div className="min-w-0 flex-1">
<h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4> <div className="flex items-baseline gap-1">
<p className="text-muted-foreground mt-1 text-xs"> <span className={cn(
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")} "truncate text-[12px] leading-tight",
</p> isActive ? "font-bold" : "font-medium",
<div className="mt-2 flex items-center justify-between border-t pt-2"> )}>
<span className="text-muted-foreground text-xs"></span> {table.displayName || table.tableName}
<Badge variant="secondary" className="text-xs"> </span>
</div>
<div className="text-muted-foreground truncate font-mono text-[10px] leading-tight tracking-tight">
{table.tableName}
</div>
</div>
<span className={cn(
"flex-shrink-0 rounded-full px-1.5 py-0.5 font-mono text-[10px] font-bold leading-none",
isActive
? "bg-primary/15 text-primary"
: "text-muted-foreground",
)}>
{table.columnCount} {table.columnCount}
</Badge> </span>
</div> </div>
</div> </div>
</div> );
</div> })
))
)} )}
</div> </div>
{/* 하단 정보 */}
<div className="text-muted-foreground flex-shrink-0 border-t px-3 py-2 text-[10px] font-medium">
{filteredTables.length} / {tables.length}
</div> </div>
} </div>
right={
<div className="flex h-full flex-col overflow-hidden"> {/* 중앙: 컬럼 그리드 */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{!selectedTable ? ( {!selectedTable ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border"> <div className="flex flex-1 flex-col items-center justify-center gap-2">
<div className="flex flex-col items-center gap-2 text-center"> <Database className="text-muted-foreground/40 h-10 w-10" />
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")} {getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")}
</p> </p>
</div> </div>
</div>
) : ( ) : (
<> <>
{/* 테이블 라벨 설정 + 저장 버튼 (고정 영역) */} {/* 중앙 헤더: 테이블명 + 라벨 입력 + 저장 */}
<div className="mb-4 flex items-center gap-4"> <div className="bg-card flex flex-shrink-0 items-center gap-3 border-b px-5 py-3">
<div className="flex-1"> <div className="min-w-0 flex-shrink-0">
<div className="text-[15px] font-bold tracking-tight">
{tableLabel || selectedTable}
</div>
<div className="text-muted-foreground font-mono text-[11px] tracking-tight">
{selectedTable}
</div>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2">
<Input <Input
value={tableLabel} value={tableLabel}
onChange={(e) => setTableLabel(e.target.value)} onChange={(e) => setTableLabel(e.target.value)}
placeholder="테이블 표시명" placeholder="표시명"
className="h-10 text-sm" className="h-8 max-w-[160px] text-xs"
/> />
</div>
<div className="flex-1">
<Input <Input
value={tableDescription} value={tableDescription}
onChange={(e) => setTableDescription(e.target.value)} onChange={(e) => setTableDescription(e.target.value)}
placeholder="테이블 설명" placeholder="설명"
className="h-10 text-sm" className="h-8 max-w-[200px] text-xs"
/> />
</div> </div>
{/* 저장 버튼 (항상 보이도록 상단에 배치) */}
<Button <Button
onClick={saveAllSettings} onClick={saveAllSettings}
disabled={!selectedTable || columns.length === 0 || isSaving} disabled={!selectedTable || columns.length === 0 || isSaving}
className="h-10 gap-2 text-sm font-medium" size="sm"
className="h-8 gap-1.5 text-xs"
> >
{isSaving ? ( {isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
) : ( ) : (
<Settings className="h-4 w-4" /> <Save className="h-3.5 w-3.5" />
)} )}
{isSaving ? "저장 중..." : "전체 설정 저장"} {isSaving ? "저장 중..." : "전체 설정 저장"}
</Button> </Button>
</div> </div>
{columnsLoading ? ( {columnsLoading ? (
<div className="flex items-center justify-center py-8"> <div className="flex flex-1 items-center justify-center">
<LoadingSpinner /> <LoadingSpinner />
<span className="text-muted-foreground ml-2 text-sm"> <span className="text-muted-foreground ml-2 text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")} {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")}
</span> </span>
</div> </div>
) : columns.length === 0 ? ( ) : columns.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm"> <div className="text-muted-foreground flex flex-1 items-center justify-center text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")} {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
</div> </div>
) : ( ) : (
<div className="flex flex-1 flex-col overflow-hidden">
{/* 컬럼 헤더 (고정) */}
<div
className="text-foreground grid h-12 flex-shrink-0 items-center border-b px-6 py-3 text-sm font-semibold"
style={{ gridTemplateColumns: "160px 200px 250px minmax(80px,1fr) 70px 70px 70px 70px" }}
>
<div className="pr-4"></div>
<div className="px-4"></div>
<div className="pr-6"> </div>
<div className="pl-4"></div>
<div className="text-center text-xs">Primary</div>
<div className="text-center text-xs">NotNull</div>
<div className="text-center text-xs">Index</div>
<div className="text-center text-xs">Unique</div>
</div>
{/* 컬럼 리스트 (스크롤 영역) */}
<div
className="flex-1 overflow-y-auto"
onScroll={(e) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
// 스크롤이 끝에 가까워지면 더 많은 데이터 로드
if (scrollHeight - scrollTop <= clientHeight + 100) {
loadMoreColumns();
}
}}
>
{columns.map((column, index) => {
const idxState = getColumnIndexState(column.columnName);
return (
<div
key={column.columnName}
className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors"
style={{ gridTemplateColumns: "160px 200px 250px minmax(80px,1fr) 70px 70px 70px 70px" }}
>
<div className="pr-4">
<Input
value={column.displayName || ""}
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
placeholder={column.columnName}
className="h-8 text-xs"
/>
</div>
<div className="px-4 pt-1">
<div className="font-mono text-sm">{column.columnName}</div>
</div>
<div className="pr-6">
<div className="space-y-3">
{/* 입력 타입 선택 */}
<Select
value={column.inputType || "text"}
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="입력 타입 선택" />
</SelectTrigger>
<SelectContent>
{memoizedInputTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 입력 타입이 'code'인 경우 공통코드 선택 */}
{column.inputType === "code" && (
<> <>
<Select <TypeOverviewStrip
value={column.codeCategory || "none"} columns={columns}
onValueChange={(value) => activeFilter={typeFilter}
handleDetailSettingsChange(column.columnName, "code", value) onFilterChange={setTypeFilter}
/>
<ColumnGrid
columns={columns}
selectedColumn={selectedColumn}
onSelectColumn={setSelectedColumn}
onColumnChange={(columnName, field, value) => {
const idx = columns.findIndex((c) => c.columnName === columnName);
if (idx >= 0) handleColumnChange(idx, field, value);
}}
constraints={constraints}
typeFilter={typeFilter}
getColumnIndexState={getColumnIndexState}
onPkToggle={handlePkToggle}
onIndexToggle={(columnName, checked) =>
handleIndexToggle(columnName, "index", checked)
} }
> />
<SelectTrigger className="h-8 text-xs"> </>
<SelectValue placeholder="공통코드 선택" />
</SelectTrigger>
<SelectContent>
{commonCodeOptions.map((option, index) => (
<SelectItem key={`code-${option.value}-${index}`} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 계층구조 역할 선택 */}
{column.codeCategory && column.codeCategory !== "none" && (
<Select
value={column.hierarchyRole || "none"}
onValueChange={(value) =>
handleDetailSettingsChange(column.columnName, "hierarchy_role", value)
}
>
<SelectTrigger className="h-8 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>
{column.inputType === "category" && (
<div className="w-56"> {/* 우측: 상세 패널 (selectedColumn 있을 때만) */}
<label className="text-muted-foreground mb-1 block text-xs"> ()</label> {selectedColumn && (
<Input <div className="w-[320px] min-w-[320px] flex-shrink-0 overflow-hidden">
value={column.categoryRef || ""} <ColumnDetailPanel
onChange={(e) => { column={columns.find((c) => c.columnName === selectedColumn) ?? null}
const val = e.target.value || null; tables={tables}
referenceTableColumns={referenceTableColumns}
secondLevelMenus={secondLevelMenus}
numberingRules={numberingRules}
onColumnChange={(field, value) => {
if (!selectedColumn) return;
if (field === "inputType") {
handleInputTypeChange(selectedColumn, value as string);
return;
}
if (field === "referenceTable" && value) {
loadReferenceTableColumns(value as string);
}
setColumns((prev) => setColumns((prev) =>
prev.map((c) => prev.map((c) =>
c.columnName === column.columnName c.columnName === selectedColumn ? { ...c, [field]: value } : c,
? { ...c, categoryRef: val } ),
: c
)
); );
}} }}
placeholder="테이블명.컬럼명" onClose={() => setSelectedColumn(null)}
className="h-8 text-xs" onLoadReferenceColumns={loadReferenceTableColumns}
/> codeCategoryOptions={commonCodeOptions}
<p className="text-muted-foreground mt-0.5 text-[10px]"> referenceTableOptions={referenceTableOptions}
</p>
</div>
)}
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
{column.inputType === "entity" && (
<>
{/* 참조 테이블 - 검색 가능한 Combobox */}
<div className="w-56">
<label className="text-muted-foreground mb-1 block text-xs"> </label>
<Popover
open={entityComboboxOpen[column.columnName]?.table || false}
onOpenChange={(open) =>
setEntityComboboxOpen((prev) => ({
...prev,
[column.columnName]: { ...prev[column.columnName], table: open },
}))
}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={entityComboboxOpen[column.columnName]?.table || false}
className="bg-background h-8 w-full justify-between text-xs"
>
{column.referenceTable && column.referenceTable !== "none"
? referenceTableOptions.find((opt) => opt.value === column.referenceTable)
?.label || column.referenceTable
: "테이블 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 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>
{referenceTableOptions.map((option) => (
<CommandItem
key={option.value}
value={`${option.label} ${option.value}`}
onSelect={() => {
handleDetailSettingsChange(
column.columnName,
"entity",
option.value,
);
setEntityComboboxOpen((prev) => ({
...prev,
[column.columnName]: {
...prev[column.columnName],
table: false,
},
}));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
column.referenceTable === option.value
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{option.label}</span>
{option.value !== "none" && (
<span className="text-muted-foreground text-[10px]">
{option.value}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 조인 컬럼 - 검색 가능한 Combobox */}
{column.referenceTable && column.referenceTable !== "none" && (
<div className="w-56">
<label className="text-muted-foreground mb-1 block text-xs"> </label>
<Popover
open={entityComboboxOpen[column.columnName]?.joinColumn || false}
onOpenChange={(open) =>
setEntityComboboxOpen((prev) => ({
...prev,
[column.columnName]: { ...prev[column.columnName], joinColumn: open },
}))
}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={entityComboboxOpen[column.columnName]?.joinColumn || false}
className="bg-background h-8 w-full justify-between text-xs"
disabled={
!referenceTableColumns[column.referenceTable] ||
referenceTableColumns[column.referenceTable].length === 0
}
>
{!referenceTableColumns[column.referenceTable] ||
referenceTableColumns[column.referenceTable].length === 0 ? (
<span className="flex items-center gap-2">
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
...
</span>
) : column.referenceColumn && column.referenceColumn !== "none" ? (
column.referenceColumn
) : (
"컬럼 선택..."
)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 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={() => {
handleDetailSettingsChange(
column.columnName,
"entity_reference_column",
"none",
);
setEntityComboboxOpen((prev) => ({
...prev,
[column.columnName]: {
...prev[column.columnName],
joinColumn: false,
},
}));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
column.referenceColumn === "none" || !column.referenceColumn
? "opacity-100"
: "opacity-0",
)}
/>
-- --
</CommandItem>
{referenceTableColumns[column.referenceTable]?.map((refCol) => (
<CommandItem
key={refCol.columnName}
value={`${refCol.displayName || ""} ${refCol.columnName}`}
onSelect={() => {
handleDetailSettingsChange(
column.columnName,
"entity_reference_column",
refCol.columnName,
);
setEntityComboboxOpen((prev) => ({
...prev,
[column.columnName]: {
...prev[column.columnName],
joinColumn: false,
},
}));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
column.referenceColumn === refCol.columnName
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{refCol.columnName}</span>
{refCol.displayName && (
<span className="text-muted-foreground text-[10px]">
{refCol.displayName}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* 설정 완료 표시 */}
{column.referenceTable &&
column.referenceTable !== "none" &&
column.referenceColumn &&
column.referenceColumn !== "none" && (
<div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs">
<Check className="h-3 w-3" />
<span className="truncate"> </span>
</div>
)}
</>
)}
{/* 채번 타입은 옵션설정 > 채번설정에서 관리 (별도 선택 불필요) */}
</div>
</div>
<div className="pl-4">
<Input
value={column.description || ""}
onChange={(e) => handleColumnChange(index, "description", e.target.value)}
placeholder="설명"
className="h-8 w-full text-xs"
/> />
</div> </div>
{/* PK 체크박스 */}
<div className="flex items-center justify-center pt-1">
<Checkbox
checked={idxState.isPk}
onCheckedChange={(checked) =>
handlePkToggle(column.columnName, checked as boolean)
}
aria-label={`${column.columnName} PK 설정`}
/>
</div>
{/* NN (NOT NULL) 체크박스 */}
<div className="flex items-center justify-center pt-1">
<Checkbox
checked={column.isNullable === "NO"}
onCheckedChange={() =>
handleNullableToggle(column.columnName, column.isNullable)
}
aria-label={`${column.columnName} NOT NULL 설정`}
/>
</div>
{/* IDX 체크박스 */}
<div className="flex items-center justify-center pt-1">
<Checkbox
checked={idxState.hasIndex}
onCheckedChange={(checked) =>
handleIndexToggle(column.columnName, "index", checked as boolean)
}
aria-label={`${column.columnName} 인덱스 설정`}
/>
</div>
{/* UQ 체크박스 (앱 레벨 소프트 제약조건) */}
<div className="flex items-center justify-center pt-1">
<Checkbox
checked={column.isUnique === "YES"}
onCheckedChange={() =>
handleUniqueToggle(column.columnName, column.isUnique)
}
aria-label={`${column.columnName} 유니크 설정`}
/>
</div>
</div>
);
})}
{/* 로딩 표시 */}
{columnsLoading && (
<div className="flex items-center justify-center py-4">
<LoadingSpinner />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
</div>
)} )}
</div> </div>
{/* 페이지 정보 (고정 하단) */}
<div className="text-muted-foreground flex-shrink-0 border-t py-2 text-center text-sm">
{columns.length} / {totalColumns}
</div>
</div>
)}
</>
)}
</div>
}
leftTitle="테이블 목록"
leftWidth={20}
minLeftWidth={10}
maxLeftWidth={35}
height="100%"
className="flex-1 overflow-hidden"
/>
{/* DDL 모달 컴포넌트들 */} {/* DDL 모달 컴포넌트들 */}
{isSuperAdmin && ( {isSuperAdmin && (
<> <>
@ -2205,6 +1828,5 @@ export default function TableManagementPage() {
{/* Scroll to Top 버튼 */} {/* Scroll to Top 버튼 */}
<ScrollToTop /> <ScrollToTop />
</div> </div>
</div>
); );
} }

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: { size: {
...splitAdjustedComponent.size, ...splitAdjustedComponent.size,
width: undefined as unknown as number, 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 적용 // - 버튼 컴포넌트: buttonElementStyle에서 자체 border 적용
const isV2HorizLabel = !!( const isV2HorizLabel = !!(
componentStyle && componentStyle &&
(componentStyle.labelDisplay === true || componentStyle.labelDisplay === "true") && componentStyle.labelDisplay !== false && componentStyle.labelDisplay !== "false" &&
(componentStyle.labelPosition === "left" || componentStyle.labelPosition === "right") (componentStyle.labelPosition === "left" || componentStyle.labelPosition === "right")
); );
const needsStripBorder = isV2HorizLabel || isButtonComponent; const needsStripBorder = isV2HorizLabel || isButtonComponent;

View File

@ -2,8 +2,6 @@
import React, { useRef, useState, useEffect } from "react"; import React, { useRef, useState, useEffect } from "react";
import { ComponentData } from "@/types/screen"; import { ComponentData } from "@/types/screen";
import { useResponsive } from "@/lib/hooks/useResponsive";
import { cn } from "@/lib/utils";
interface ResponsiveGridRendererProps { interface ResponsiveGridRendererProps {
components: ComponentData[]; components: ComponentData[];
@ -12,60 +10,6 @@ interface ResponsiveGridRendererProps {
renderComponent: (component: ComponentData) => React.ReactNode; 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 { function getComponentTypeId(component: ComponentData): string {
const direct = const direct =
(component as any).componentType || (component as any).widgetType; (component as any).componentType || (component as any).widgetType;
@ -78,132 +22,10 @@ function getComponentTypeId(component: ComponentData): string {
return component.type || ""; 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({ function ProportionalRenderer({
components, components,
canvasWidth, canvasWidth,
@ -270,12 +92,6 @@ export function ResponsiveGridRenderer({
canvasHeight, canvasHeight,
renderComponent, renderComponent,
}: ResponsiveGridRendererProps) { }: ResponsiveGridRendererProps) {
const { isMobile } = useResponsive();
const topLevel = components.filter((c) => !c.parentId);
const hasFullWidthComponent = topLevel.some((c) => isFullWidthComponent(c));
if (!isMobile && !hasFullWidthComponent) {
return ( return (
<ProportionalRenderer <ProportionalRenderer
components={components} components={components}
@ -284,207 +100,6 @@ export function ResponsiveGridRenderer({
renderComponent={renderComponent} 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>
);
} }
export default ResponsiveGridRenderer; export default ResponsiveGridRenderer;

View File

@ -8,7 +8,6 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@ -240,14 +239,14 @@ export function ScreenSettingModal({
componentCount = 0, componentCount = 0,
onSaveSuccess, onSaveSuccess,
}: ScreenSettingModalProps) { }: ScreenSettingModalProps) {
const [activeTab, setActiveTab] = useState("overview");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [dataFlows, setDataFlows] = useState<DataFlow[]>([]); const [dataFlows, setDataFlows] = useState<DataFlow[]>([]);
const [layoutItems, setLayoutItems] = useState<LayoutItem[]>([]); const [layoutItems, setLayoutItems] = useState<LayoutItem[]>([]);
const [iframeKey, setIframeKey] = useState(0); // iframe 새로고침용 키 const [buttonControls, setButtonControls] = useState<ButtonControlInfo[]>([]);
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); // 화면 캔버스 크기 const [iframeKey, setIframeKey] = useState(0);
const [showDesignerModal, setShowDesignerModal] = useState(false); // 화면 디자이너 모달 const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
const [showTableSettingModal, setShowTableSettingModal] = useState(false); // 테이블 설정 모달 const [showDesignerModal, setShowDesignerModal] = useState(false);
const [showTableSettingModal, setShowTableSettingModal] = useState(false);
const [tableSettingTarget, setTableSettingTarget] = useState<{ tableName: string; tableLabel?: string } | null>(null); const [tableSettingTarget, setTableSettingTarget] = useState<{ tableName: string; tableLabel?: string } | null>(null);
// 그룹 내 화면 목록 및 현재 선택된 화면 // 그룹 내 화면 목록 및 현재 선택된 화면
@ -338,12 +337,56 @@ export function ScreenSettingModal({
if (layoutResponse.success && layoutResponse.data) { if (layoutResponse.success && layoutResponse.data) {
const screenLayout = layoutResponse.data[currentScreenId]; const screenLayout = layoutResponse.data[currentScreenId];
setLayoutItems(screenLayout?.layoutItems || []); setLayoutItems(screenLayout?.layoutItems || []);
// 캔버스 크기 저장 (화면 프리뷰에 사용)
setCanvasSize({ setCanvasSize({
width: screenLayout?.canvasWidth || 0, width: screenLayout?.canvasWidth || 0,
height: screenLayout?.canvasHeight || 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) { } catch (error) {
console.error("데이터 로드 실패:", error); console.error("데이터 로드 실패:", error);
} finally { } finally {
@ -360,23 +403,52 @@ export function ScreenSettingModal({
// 새로고침 (데이터 + iframe) // 새로고침 (데이터 + iframe)
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
loadData(); loadData();
setIframeKey(prev => prev + 1); // iframe 새로고침 setIframeKey(prev => prev + 1);
}, [loadData]); }, [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 ( return (
<> <>
<Dialog open={isOpen && !showDesignerModal} onOpenChange={onClose}> <Dialog open={isOpen && !showDesignerModal} onOpenChange={onClose}>
<DialogContent className="flex h-[90vh] max-h-[950px] w-[98vw] max-w-[1600px] flex-col"> <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">
<DialogHeader className="flex-shrink-0"> {/* V3 Header */}
<DialogTitle className="flex items-center gap-2 text-lg"> <DialogHeader className="flex-shrink-0 pb-0">
<Settings2 className="h-5 w-5 text-primary" /> <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)]" />
{groupScreens.length > 1 ? ( <span className="font-bold tracking-tight">{currentScreenName}</span>
{groupScreens.length > 1 && (
<>
<span className="h-3.5 w-px bg-border" />
<Select <Select
value={currentScreenId.toString()} value={currentScreenId.toString()}
onValueChange={(value) => handleScreenChange(parseInt(value, 10))} onValueChange={(value) => handleScreenChange(parseInt(value, 10))}
> >
<SelectTrigger className="h-8 w-auto min-w-[200px] max-w-[400px] text-base font-semibold"> <SelectTrigger className="h-7 w-auto min-w-[140px] max-w-[280px] border-border/40 bg-muted/30 text-xs font-medium">
<SelectValue placeholder="화면 선택" /> <SelectValue placeholder="화면 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -384,134 +456,238 @@ export function ScreenSettingModal({
<SelectItem key={screen.screen_id} value={screen.screen_id.toString()}> <SelectItem key={screen.screen_id} value={screen.screen_id.toString()}>
{screen.screen_name} {screen.screen_name}
{screen.screen_role && ( {screen.screen_role && (
<span className="ml-2 text-xs text-muted-foreground"> <span className="ml-1 text-muted-foreground">({screen.screen_role})</span>
({screen.screen_role})
</span>
)} )}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
) : ( </>
<span>{currentScreenName}</span>
)} )}
</DialogTitle> <span className="ml-auto font-mono text-[10px] text-muted-foreground/60">#{currentScreenId}</span>
<DialogDescription className="text-sm"> <Button variant="ghost" size="sm" onClick={handleRefresh} className="h-7 w-7 p-0" title="새로고침">
, , .
</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")} /> <RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
</Button> </Button>
</DialogTitle>
<DialogDescription className="sr-only"> </DialogDescription>
</DialogHeader>
{/* 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>
<div className="my-4 h-px bg-border/40" />
{/* 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>
)}
</div>
<div className="my-4 h-px bg-border/40" />
{/* 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 <Button
variant="outline" 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"
size="sm"
onClick={() => setShowDesignerModal(true)} onClick={() => setShowDesignerModal(true)}
className="h-7 px-2 text-xs gap-1"
title="화면 디자이너에서 상세 편집"
> >
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
</Button> </Button>
</div> </div>
</div> </div>
{/* 탭 1: 화면 개요 */} {/* 오른쪽: 화면 프리뷰 */}
<TabsContent value="overview" className="mt-0 min-h-0 flex-1 overflow-auto p-3"> <div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-border/40">
<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>
{/* 탭 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} // 임베드 모드
/>
)}
</TabsContent>
{/* 탭 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>
{/* 탭 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>
</div>
{/* 오른쪽: 화면 프리뷰 (60%, 항상 표시) */}
<div className="flex min-h-0 w-[60%] flex-col overflow-hidden rounded-lg border bg-card">
<PreviewTab <PreviewTab
screenId={currentScreenId} screenId={currentScreenId}
screenName={currentScreenName} screenName={currentScreenName}

View File

@ -102,7 +102,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
const fetchScreens = async () => { const fetchScreens = async () => {
try { try {
setScreensLoading(true); 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)) { if (response.data.success && Array.isArray(response.data.data)) {
const screenList = response.data.data.map((screen: any) => ({ const screenList = response.data.data.map((screen: any) => ({

View File

@ -34,7 +34,8 @@ const FORMAT_PATTERNS: Record<V2InputFormat, { pattern: RegExp; placeholder: str
errorMessage: "올바른 이메일 형식이 아닙니다", errorMessage: "올바른 이메일 형식이 아닙니다",
}, },
tel: { 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", placeholder: "010-1234-5678",
errorMessage: "올바른 전화번호 형식이 아닙니다", errorMessage: "올바른 전화번호 형식이 아닙니다",
}, },
@ -80,8 +81,34 @@ function formatBizNo(value: string): string {
// 전화번호 형식 변환 // 전화번호 형식 변환
function formatTel(value: string): string { function formatTel(value: string): string {
const digits = value.replace(/\D/g, ""); 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 <= 3) return digits;
if (digits.length <= 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`; 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)}`; 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)}`; 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 hasCustomRadius = !!style?.borderRadius;
const customTextStyle: React.CSSProperties = {}; 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?.fontSize) customTextStyle.fontSize = style.fontSize;
if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight; if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight;
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"]; 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} ref={ref}
id={id} id={id}
className={cn( className={cn(
"flex flex-col gap-1", "flex gap-1",
labelPos === "left" ? "sm:flex-row sm:items-center" : "sm:flex-row-reverse sm:items-center", labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center",
)} )}
style={{ style={{
width: componentWidth, width: componentWidth,
@ -1191,7 +1218,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
color: getAdaptiveLabelColor(style?.labelColor), color: getAdaptiveLabelColor(style?.labelColor),
fontWeight: style?.labelFontWeight || "500", 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} {actualLabel}
{required && <span className="ml-0.5 text-amber-500">*</span>} {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} ref={ref}
id={id} id={id}
className={cn( className={cn(
"flex flex-col gap-1", "flex gap-1",
labelPos === "left" ? "sm:flex-row sm:items-center" : "sm:flex-row-reverse sm:items-center", labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center",
isDesignMode && "pointer-events-none", isDesignMode && "pointer-events-none",
)} )}
style={{ style={{
@ -1308,7 +1308,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>((props, ref) =
color: getAdaptiveLabelColor(style?.labelColor), color: getAdaptiveLabelColor(style?.labelColor),
fontWeight: style?.labelFontWeight || "500", 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} {label}
{required && <span className="ml-0.5 text-amber-500">*</span>} {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 [modalScreenOpen, setModalScreenOpen] = useState(false);
const [modalSearchTerm, setModalSearchTerm] = useState(""); 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 showIconSettings = displayMode === "icon" || displayMode === "icon-text";
const currentActionIcons = actionIconMap[actionType] || []; const currentActionIcons = actionIconMap[actionType] || [];
const isNoIconAction = noIconActions.has(actionType); const isNoIconAction = noIconActions.has(actionType);
@ -330,6 +337,76 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
setIconSize(config.icon?.size || "보통"); setIconSize(config.icon?.size || "보통");
}, [config.icon?.name, config.icon?.type, 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(() => { useEffect(() => {
if (actionType !== "modal" && actionType !== "navigate") return; if (actionType !== "modal" && actionType !== "navigate") return;
@ -338,7 +415,7 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
const loadScreens = async () => { const loadScreens = async () => {
setScreensLoading(true); setScreensLoading(true);
try { 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) { if (response.data.success && response.data.data) {
const screenList = response.data.data.map((s: any) => ({ const screenList = response.data.data.map((s: any) => ({
id: s.id || s.screenId, id: s.id || s.screenId,
@ -521,6 +598,8 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
modalSearchTerm={modalSearchTerm} modalSearchTerm={modalSearchTerm}
setModalSearchTerm={setModalSearchTerm} setModalSearchTerm={setModalSearchTerm}
currentTableName={effectiveTableName} currentTableName={effectiveTableName}
allComponents={allComponents}
handleUpdateProperty={handleUpdateProperty}
/> />
{/* ─── 아이콘 설정 (접기) ─── */} {/* ─── 아이콘 설정 (접기) ─── */}
@ -657,6 +736,26 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
)} )}
</div> </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" && ( {actionType !== "excel_upload" && actionType !== "multi_table_excel_upload" && (
<> <>
@ -699,6 +798,8 @@ const ActionDetailSection: React.FC<{
modalSearchTerm: string; modalSearchTerm: string;
setModalSearchTerm: (term: string) => void; setModalSearchTerm: (term: string) => void;
currentTableName?: string; currentTableName?: string;
allComponents?: ComponentData[];
handleUpdateProperty?: (path: string, value: any) => void;
}> = ({ }> = ({
actionType, actionType,
config, config,
@ -711,6 +812,8 @@ const ActionDetailSection: React.FC<{
modalSearchTerm, modalSearchTerm,
setModalSearchTerm, setModalSearchTerm,
currentTableName, currentTableName,
allComponents = [],
handleUpdateProperty,
}) => { }) => {
const action = config.action || {}; const action = config.action || {};
@ -800,7 +903,7 @@ const ActionDetailSection: React.FC<{
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start"> <PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command> <Command shouldFilter={false}>
<CommandInput <CommandInput
placeholder="화면 검색..." placeholder="화면 검색..."
value={modalSearchTerm} value={modalSearchTerm}
@ -812,8 +915,10 @@ const ActionDetailSection: React.FC<{
<CommandGroup> <CommandGroup>
{screens {screens
.filter((s) => .filter((s) =>
!modalSearchTerm ||
s.name.toLowerCase().includes(modalSearchTerm.toLowerCase()) || 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) => ( .map((screen) => (
<CommandItem <CommandItem
@ -951,6 +1056,190 @@ const ActionDetailSection: React.FC<{
</div> </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": case "event":
return ( return (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3"> <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"; V2ButtonConfigPanel.displayName = "V2ButtonConfigPanel";
export default V2ButtonConfigPanel; export default V2ButtonConfigPanel;

View File

@ -82,9 +82,10 @@ import {
arrayMove, arrayMove,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import type { import {
SplitPanelLayoutConfig, MAX_LOAD_ALL_SIZE,
AdditionalTabConfig, type SplitPanelLayoutConfig,
type AdditionalTabConfig,
} from "@/lib/registry/components/v2-split-panel-layout/types"; } from "@/lib/registry/components/v2-split-panel-layout/types";
import type { TableInfo, ColumnInfo } from "@/types/screen"; import type { TableInfo, ColumnInfo } from "@/types/screen";
@ -1158,6 +1159,41 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
updateLeftPanel({ showItemAddButton: checked }) 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> </div>
{/* 좌측 패널 컬럼 설정 (접이식) */} {/* 좌측 패널 컬럼 설정 (접이식) */}
@ -1564,6 +1600,41 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
updateRightPanel({ showDelete: checked }) 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> </div>
{/* 우측 패널 컬럼 설정 (접이식) */} {/* 우측 패널 컬럼 설정 (접이식) */}

View File

@ -957,7 +957,38 @@ export const V2TableListConfigPanel: React.FC<V2TableListConfigPanelProps> = ({
/> />
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" /> <Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
<span className="truncate text-xs">{column.columnLabel}</span> <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} {column.inputType || column.dataType}
</span> </span>
</div> </div>

View File

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

View File

@ -1751,7 +1751,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
<TableHeader> <TableHeader>
<TableRow> <TableRow>
{displayColumns.map((col, idx) => ( {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} {col.label || col.name}
</TableHead> </TableHead>
))} ))}
@ -1952,7 +1952,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
</TableHead> </TableHead>
)} )}
{displayColumns.map((col, idx) => ( {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} {col.label || col.name}
</TableHead> </TableHead>
))} ))}

View File

@ -2,7 +2,7 @@
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react"; import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { ComponentRendererProps } from "../../types"; 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -16,6 +16,9 @@ import {
ChevronUp, ChevronUp,
Save, Save,
ChevronRight, ChevronRight,
ChevronLeft,
ChevronsLeft,
ChevronsRight,
Pencil, Pencil,
Trash2, Trash2,
Settings, Settings,
@ -48,6 +51,66 @@ import { cn } from "@/lib/utils";
import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer"; import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer";
import { BomExcelUploadModal } from "../v2-bom-tree/BomExcelUploadModal"; 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 { export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
// 추가 props // 추가 props
onUpdateComponent?: (component: any) => void; onUpdateComponent?: (component: any) => void;
@ -351,6 +414,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [columnInputTypes, setColumnInputTypes] = useState<Record<string, string>>({}); const [columnInputTypes, setColumnInputTypes] = useState<Record<string, string>>({});
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들 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 [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭, 1+ = 추가 탭
const [tabsData, setTabsData] = useState<Record<number, any[]>>({}); // 탭별 데이터 const [tabsData, setTabsData] = useState<Record<number, any[]>>({}); // 탭별 데이터
@ -919,12 +998,23 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
let columns = displayColumns; let columns = displayColumns;
// columnVisibility가 있으면 가시성 적용 // columnVisibility가 있으면 가시성 + 너비 적용
if (leftColumnVisibility.length > 0) { if (leftColumnVisibility.length > 0) {
const visibilityMap = new Map(leftColumnVisibility.map((cv) => [cv.columnName, cv.visible])); const visibilityMap = new Map(
columns = columns.filter((col: any) => { leftColumnVisibility.map((cv) => [cv.columnName, cv])
);
columns = columns
.filter((col: any) => {
const colName = typeof col === "string" ? col : col.name || col.columnName; const colName = typeof col === "string" ? col : col.name || col.columnName;
return visibilityMap.get(colName) !== false; 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,27 +1331,25 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return joinColumns.length > 0 ? joinColumns : undefined; return joinColumns.length > 0 ? joinColumns : undefined;
}, []); }, []);
// 좌측 데이터 로드 // 좌측 데이터 로드 (페이징 ON: page 파라미터 사용, OFF: 전체 로드)
const loadLeftData = useCallback(async () => { const loadLeftData = useCallback(async (page?: number, pageSizeOverride?: number) => {
const leftTableName = componentConfig.leftPanel?.tableName; const leftTableName = componentConfig.leftPanel?.tableName;
if (!leftTableName || isDesignMode) return; if (!leftTableName || isDesignMode) return;
setIsLoadingLeft(true); setIsLoadingLeft(true);
try { try {
// 🎯 필터 조건을 API에 전달 (entityJoinApi 사용)
const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined; const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined;
// 🆕 좌측 패널 config의 Entity 조인 컬럼 추출 (헬퍼 함수 사용)
const leftJoinColumns = extractAdditionalJoinColumns( const leftJoinColumns = extractAdditionalJoinColumns(
componentConfig.leftPanel?.columns, componentConfig.leftPanel?.columns,
leftTableName, leftTableName,
); );
console.log("🔗 [분할패널] 좌측 additionalJoinColumns:", leftJoinColumns); if (leftPaginationEnabled) {
const currentPageToLoad = page ?? leftCurrentPage;
const effectivePageSize = pageSizeOverride ?? leftPageSize;
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, { const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
page: 1, page: currentPageToLoad,
size: 100, size: effectivePageSize,
search: filters, search: filters,
enableEntityJoin: true, enableEntityJoin: true,
dataFilter: componentConfig.leftPanel?.dataFilter, dataFilter: componentConfig.leftPanel?.dataFilter,
@ -1269,47 +1357,24 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
companyCodeOverride: companyCode, companyCodeOverride: companyCode,
}); });
// 🔍 디버깅: API 응답 데이터의 키 확인 setLeftData(result.data || []);
if (result.data && result.data.length > 0) { setLeftCurrentPage(result.page || currentPageToLoad);
console.log("🔗 [분할패널] API 응답 첫 번째 데이터 키:", Object.keys(result.data[0])); setLeftTotalPages(result.totalPages || 1);
console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]); setLeftTotal(result.total || 0);
} setLeftPageInput(String(result.page || currentPageToLoad));
} else {
// 좌측 패널 dataFilter 클라이언트 사이드 적용 const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
let filteredLeftData = result.data || []; page: 1,
const leftDataFilter = componentConfig.leftPanel?.dataFilter; size: MAX_LOAD_ALL_SIZE,
if (leftDataFilter?.enabled && leftDataFilter.filters?.length > 0) { search: filters,
const matchFn = leftDataFilter.matchType === "any" ? "some" : "every"; enableEntityJoin: true,
filteredLeftData = filteredLeftData.filter((item: any) => { dataFilter: componentConfig.leftPanel?.dataFilter,
return leftDataFilter.filters[matchFn]((cond: any) => { additionalJoinColumns: leftJoinColumns,
const val = item[cond.columnName]; companyCodeOverride: companyCode,
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;
}
}); });
});
}
// 가나다순 정렬 (좌측 패널의 표시 컬럼 기준) let filteredLeftData = applyClientSideFilter(result.data || [], componentConfig.leftPanel?.dataFilter);
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
if (leftColumn && filteredLeftData.length > 0) { if (leftColumn && filteredLeftData.length > 0) {
filteredLeftData.sort((a, b) => { filteredLeftData.sort((a, b) => {
@ -1319,9 +1384,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}); });
} }
// 계층 구조 빌드
const hierarchicalData = buildHierarchy(filteredLeftData); const hierarchicalData = buildHierarchy(filteredLeftData);
setLeftData(hierarchicalData); setLeftData(hierarchicalData);
}
} catch (error) { } catch (error) {
console.error("좌측 데이터 로드 실패:", error); console.error("좌측 데이터 로드 실패:", error);
toast({ toast({
@ -1337,15 +1402,25 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
componentConfig.leftPanel?.columns, componentConfig.leftPanel?.columns,
componentConfig.leftPanel?.dataFilter, componentConfig.leftPanel?.dataFilter,
componentConfig.rightPanel?.relation?.leftColumn, componentConfig.rightPanel?.relation?.leftColumn,
leftPaginationEnabled,
leftCurrentPage,
leftPageSize,
isDesignMode, isDesignMode,
toast, toast,
buildHierarchy, buildHierarchy,
searchValues, 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( const loadRightData = useCallback(
async (leftItem: any) => { async (leftItem: any, page?: number, pageSizeOverride?: number) => {
const relationshipType = componentConfig.rightPanel?.relation?.type || "detail"; const relationshipType = componentConfig.rightPanel?.relation?.type || "detail";
const rightTableName = componentConfig.rightPanel?.tableName; const rightTableName = componentConfig.rightPanel?.tableName;
@ -1359,70 +1434,33 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
componentConfig.rightPanel?.columns, componentConfig.rightPanel?.columns,
rightTableName, rightTableName,
); );
const effectivePageSize = pageSizeOverride ?? rightPageSize;
if (rightPaginationEnabled) {
const currentPageToLoad = page ?? rightCurrentPage;
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
page: currentPageToLoad,
size: effectivePageSize,
enableEntityJoin: true, enableEntityJoin: true,
size: 1000,
companyCodeOverride: companyCode, companyCodeOverride: companyCode,
additionalJoinColumns: rightJoinColumns, additionalJoinColumns: rightJoinColumns,
dataFilter: componentConfig.rightPanel?.dataFilter, dataFilter: componentConfig.rightPanel?.dataFilter,
}); });
// dataFilter 적용 setRightData(result.data || []);
let filteredData = result.data || []; updateRightPaginationState(result, currentPageToLoad);
const dataFilter = componentConfig.rightPanel?.dataFilter; } else {
if (dataFilter?.enabled && dataFilter.filters?.length > 0) { const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
filteredData = filteredData.filter((item: any) => { enableEntityJoin: true,
return dataFilter.filters.every((cond: any) => { size: MAX_LOAD_ALL_SIZE,
const value = item[cond.columnName]; companyCodeOverride: companyCode,
switch (cond.operator) { additionalJoinColumns: rightJoinColumns,
case "equals": dataFilter: componentConfig.rightPanel?.dataFilter,
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;
}
}); });
});
}
// 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;
}
});
});
}
const filteredData = applyClientSideFilter(result.data || [], componentConfig.rightPanel?.dataFilter);
setRightData(filteredData); setRightData(filteredData);
}
} catch (error) { } catch (error) {
console.error("우측 전체 데이터 로드 실패:", error); console.error("우측 전체 데이터 로드 실패:", error);
} finally { } finally {
@ -1499,9 +1537,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
search: searchConditions, search: searchConditions,
enableEntityJoin: true, enableEntityJoin: true,
size: 1000, size: MAX_LOAD_ALL_SIZE,
companyCodeOverride: companyCode, companyCodeOverride: companyCode,
additionalJoinColumns: rightJoinColumnsForGroup, // 🆕 Entity 조인 컬럼 전달 additionalJoinColumns: rightJoinColumnsForGroup,
}); });
if (result.data) { if (result.data) {
allResults.push(...result.data); allResults.push(...result.data);
@ -1540,16 +1578,19 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
console.log("🔗 [분할패널] 우측 패널 additionalJoinColumns:", rightJoinColumns); console.log("🔗 [분할패널] 우측 패널 additionalJoinColumns:", rightJoinColumns);
} }
// 엔티티 조인 API로 데이터 조회 const effectivePageSize = pageSizeOverride ?? rightPageSize;
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
search: searchConditions, search: searchConditions,
enableEntityJoin: true, enableEntityJoin: true,
size: 1000, size: rightPaginationEnabled ? effectivePageSize : MAX_LOAD_ALL_SIZE,
page: rightPaginationEnabled ? (page ?? rightCurrentPage) : undefined,
companyCodeOverride: companyCode, companyCodeOverride: companyCode,
additionalJoinColumns: rightJoinColumns, additionalJoinColumns: rightJoinColumns,
}); });
console.log("🔗 [분할패널] 복합키 조회 결과:", result); if (rightPaginationEnabled) {
updateRightPaginationState(result, page ?? rightCurrentPage);
}
setRightData(result.data || []); setRightData(result.data || []);
} else { } else {
@ -1576,14 +1617,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
console.log("🔗 [분할패널] 단일키 모드 additionalJoinColumns:", rightJoinColumnsLegacy); console.log("🔗 [분할패널] 단일키 모드 additionalJoinColumns:", rightJoinColumnsLegacy);
} }
const effectivePageSizeLegacy = pageSizeOverride ?? rightPageSize;
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
search: searchConditions, search: searchConditions,
enableEntityJoin: true, enableEntityJoin: true,
size: 1000, size: rightPaginationEnabled ? effectivePageSizeLegacy : MAX_LOAD_ALL_SIZE,
page: rightPaginationEnabled ? (page ?? rightCurrentPage) : undefined,
companyCodeOverride: companyCode, companyCodeOverride: companyCode,
additionalJoinColumns: rightJoinColumnsLegacy, additionalJoinColumns: rightJoinColumnsLegacy,
}); });
if (rightPaginationEnabled) {
updateRightPaginationState(result, page ?? rightCurrentPage);
}
setRightData(result.data || []); setRightData(result.data || []);
} }
} }
@ -1604,14 +1651,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.tableName,
componentConfig.rightPanel?.relation, componentConfig.rightPanel?.relation,
componentConfig.leftPanel?.tableName, componentConfig.leftPanel?.tableName,
rightPaginationEnabled,
rightCurrentPage,
rightPageSize,
isDesignMode, isDesignMode,
toast, toast,
updateRightPaginationState,
], ],
); );
// 추가 탭 데이터 로딩 함수 (leftItem이 null이면 전체 데이터 로드) // 추가 탭 데이터 로딩 함수 (leftItem이 null이면 전체 데이터 로드, page: 서버 페이징용)
const loadTabData = useCallback( const loadTabData = useCallback(
async (tabIndex: number, leftItem: any) => { async (tabIndex: number, leftItem: any, page?: number, pageSizeOverride?: number) => {
const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1]; const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1];
if (!tabConfig || isDesignMode) return; if (!tabConfig || isDesignMode) return;
@ -1623,109 +1674,73 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const keys = tabConfig.relation?.keys; const keys = tabConfig.relation?.keys;
const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn; const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn;
const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn; const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn;
// 탭 config의 Entity 조인 컬럼 추출
const tabJoinColumns = extractAdditionalJoinColumns(tabConfig.columns, tabTableName); const tabJoinColumns = extractAdditionalJoinColumns(tabConfig.columns, tabTableName);
if (tabJoinColumns) {
console.log(`🔗 [분할패널] 탭 ${tabIndex} additionalJoinColumns:`, tabJoinColumns);
}
let resultData: any[] = []; let resultData: any[] = [];
let apiResult: any = null;
// 탭의 dataFilter (API 전달용)
const tabDataFilterForApi = (tabConfig as any).dataFilter; const tabDataFilterForApi = (tabConfig as any).dataFilter;
// 탭의 relation type 확인 (detail이면 초기 전체 로드 안 함)
const tabRelationType = tabConfig.relation?.type || "join"; const tabRelationType = tabConfig.relation?.type || "join";
if (!leftItem) { const tabPagState = tabsPagination[tabIndex];
if (tabRelationType === "detail") { const currentTabPage = page ?? tabPagState?.currentPage ?? 1;
// detail 모드: 선택 안 하면 아무것도 안 뜸 const currentTabPageSize = pageSizeOverride ?? tabPagState?.pageSize ?? rightPageSize;
resultData = []; const apiSize = rightPaginationEnabled ? currentTabPageSize : MAX_LOAD_ALL_SIZE;
} else { const apiPage = rightPaginationEnabled ? currentTabPage : undefined;
// join 모드: 좌측 미선택 시 전체 데이터 로드 (dataFilter는 API에 전달)
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { const commonApiParams = {
enableEntityJoin: true, enableEntityJoin: true,
size: 1000, size: apiSize,
page: apiPage,
companyCodeOverride: companyCode, companyCodeOverride: companyCode,
additionalJoinColumns: tabJoinColumns, additionalJoinColumns: tabJoinColumns,
dataFilter: tabDataFilterForApi, dataFilter: tabDataFilterForApi,
}); };
resultData = result.data || [];
if (!leftItem) {
if (tabRelationType !== "detail") {
apiResult = await entityJoinApi.getTableDataWithJoins(tabTableName, commonApiParams);
resultData = apiResult.data || [];
} }
} else if (leftColumn && rightColumn) { } else if (leftColumn && rightColumn) {
const searchConditions: Record<string, any> = {}; const searchConditions: Record<string, any> = {};
if (keys && keys.length > 0) { if (keys && keys.length > 0) {
keys.forEach((key: any) => { keys.forEach((key: any) => {
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) { if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
searchConditions[key.rightColumn] = { searchConditions[key.rightColumn] = { value: leftItem[key.leftColumn], operator: "equals" };
value: leftItem[key.leftColumn],
operator: "equals",
};
} }
}); });
} else { } else {
const leftValue = leftItem[leftColumn]; const leftValue = leftItem[leftColumn];
if (leftValue !== undefined) { if (leftValue !== undefined) {
searchConditions[rightColumn] = { searchConditions[rightColumn] = { value: leftValue, operator: "equals" };
value: leftValue,
operator: "equals",
};
} }
} }
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { apiResult = await entityJoinApi.getTableDataWithJoins(tabTableName, {
search: searchConditions, search: searchConditions,
enableEntityJoin: true, ...commonApiParams,
size: 1000,
companyCodeOverride: companyCode,
additionalJoinColumns: tabJoinColumns,
dataFilter: tabDataFilterForApi,
}); });
resultData = result.data || []; resultData = apiResult.data || [];
} else { } else {
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { apiResult = await entityJoinApi.getTableDataWithJoins(tabTableName, commonApiParams);
enableEntityJoin: true, resultData = apiResult.data || [];
size: 1000,
companyCodeOverride: companyCode,
additionalJoinColumns: tabJoinColumns,
dataFilter: tabDataFilterForApi,
});
resultData = result.data || [];
} }
// 탭별 dataFilter 적용 // 공통 페이징 상태 업데이트
const tabDataFilter = (tabConfig as any).dataFilter; if (rightPaginationEnabled && apiResult) {
if (tabDataFilter?.enabled && tabDataFilter.filters?.length > 0) { setTabsPagination((prev) => ({
resultData = resultData.filter((item: any) => { ...prev,
return tabDataFilter.filters.every((cond: any) => { [tabIndex]: {
const value = item[cond.columnName]; currentPage: apiResult.page || currentTabPage,
switch (cond.operator) { totalPages: apiResult.totalPages || 1,
case "equals": total: apiResult.total || 0,
return value === cond.value; pageSize: currentTabPageSize,
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]; if (!rightPaginationEnabled) {
return !arr.includes(value); resultData = applyClientSideFilter(resultData, (tabConfig as any).dataFilter);
}
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;
}
});
});
} }
setTabsData((prev) => ({ ...prev, [tabIndex]: resultData })); setTabsData((prev) => ({ ...prev, [tabIndex]: resultData }));
@ -1740,9 +1755,148 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
setTabsLoading((prev) => ({ ...prev, [tabIndex]: false })); 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( const handleTabChange = useCallback(
(newTabIndex: number) => { (newTabIndex: number) => {
@ -1779,12 +1933,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
selectedLeftItem[leftPk] === item[leftPk]; selectedLeftItem[leftPk] === item[leftPk];
if (isSameItem) { if (isSameItem) {
// 선택 해제
setSelectedLeftItem(null); setSelectedLeftItem(null);
setCustomLeftSelectedData({}); setCustomLeftSelectedData({});
setExpandedRightItems(new Set()); setExpandedRightItems(new Set());
setTabsData({}); setTabsData({});
// 우측/탭 페이지 리셋
if (rightPaginationEnabled) {
setRightCurrentPage(1);
setRightPageInput("1");
setTabsPagination({});
}
const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail"; const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail";
if (mainRelationType === "detail") { if (mainRelationType === "detail") {
// "선택 시 표시" 모드: 선택 해제 시 데이터 비움 // "선택 시 표시" 모드: 선택 해제 시 데이터 비움
@ -1809,15 +1969,21 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
} }
setSelectedLeftItem(item); setSelectedLeftItem(item);
setCustomLeftSelectedData(item); // 커스텀 모드 우측 폼에 선택된 데이터 전달 setCustomLeftSelectedData(item);
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화 setExpandedRightItems(new Set());
setTabsData({}); // 모든 탭 데이터 초기화 setTabsData({});
// 우측/탭 페이지 리셋
if (rightPaginationEnabled) {
setRightCurrentPage(1);
setRightPageInput("1");
setTabsPagination({});
}
// 현재 활성 탭에 따라 데이터 로드
if (activeTabIndex === 0) { if (activeTabIndex === 0) {
loadRightData(item); loadRightData(item, 1);
} else { } else {
loadTabData(activeTabIndex, item); loadTabData(activeTabIndex, item, 1);
} }
// modalDataStore에 선택된 좌측 항목 저장 (단일 선택) // 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDesignMode, componentConfig.autoLoad]); }, [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(() => { useEffect(() => {
if (!isDesignMode && componentConfig.autoLoad !== false) { if (!isDesignMode && componentConfig.autoLoad !== false) {
loadLeftData(); if (leftPaginationEnabled) {
setLeftCurrentPage(1);
setLeftPageInput("1");
}
loadLeftData(1);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [leftFilters]); }, [leftFilters]);
@ -3547,12 +3733,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
format: undefined, // 🆕 기본값 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 && ( const hasGroupedLeftActions = !isDesignMode && (
(componentConfig.leftPanel?.showEdit !== false) || (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"> <div className="bg-muted px-3 py-2 text-sm font-semibold">
{group.groupKey} ({group.count}) {group.groupKey} ({group.count})
</div> </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"> <thead className="bg-muted">
<tr> <tr>
{columnsToShow.map((col, idx) => ( {columnsToShow.map((col, idx) => (
@ -3574,7 +3754,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
key={idx} key={idx}
className="px-3 py-2 text-left text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" className="px-3 py-2 text-left text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap"
style={{ style={{
width: col.width && col.width <= 100 ? `${col.width}%` : "auto", minWidth: col.width ? `${col.width}px` : "80px",
textAlign: col.align || "left", textAlign: col.align || "left",
}} }}
> >
@ -3663,7 +3843,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
); );
return ( return (
<div className="overflow-auto"> <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"> <thead className="sticky top-0 z-10 bg-muted">
<tr> <tr>
{columnsToShow.map((col, idx) => ( {columnsToShow.map((col, idx) => (
@ -3671,7 +3851,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
key={idx} key={idx}
className="px-3 py-2 text-left text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" className="px-3 py-2 text-left text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap"
style={{ style={{
width: col.width && col.width <= 100 ? `${col.width}%` : "auto", minWidth: col.width ? `${col.width}px` : "80px",
textAlign: col.align || "left", textAlign: col.align || "left",
}} }}
> >
@ -4008,6 +4188,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div> </div>
)} )}
</CardContent> </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> </Card>
</div> </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 ( return (
<div className="flex h-full w-full flex-col"> <div className="flex h-full w-full flex-col">
<div className="min-h-0 flex-1 overflow-auto"> <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"> <thead className="sticky top-0 z-10">
<tr className="border-b-2 border-border/60"> <tr className="border-b-2 border-border/60">
{columnsToShow.map((col, idx) => ( {columnsToShow.map((col, idx) => (
@ -4683,7 +4873,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
key={idx} key={idx}
className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap" className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap"
style={{ style={{
width: col.width && col.width <= 100 ? `${col.width}%` : "auto", minWidth: col.width ? `${col.width}px` : "80px",
textAlign: col.align || "left", 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 hasEditButton = !isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true);
const hasDeleteButton = !isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true); const hasDeleteButton = !isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true);
const hasActions = hasEditButton || hasDeleteButton; const hasActions = hasEditButton || hasDeleteButton;
@ -4809,14 +4993,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return filteredData.length > 0 ? ( return filteredData.length > 0 ? (
<div className="flex h-full w-full flex-col"> <div className="flex h-full w-full flex-col">
<div className="min-h-0 flex-1 overflow-auto"> <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"> <thead className="sticky top-0 z-10 bg-background">
<tr className="border-b-2 border-border/60"> <tr className="border-b-2 border-border/60">
{columnsToDisplay.map((col) => ( {columnsToDisplay.map((col) => (
<th <th
key={col.name} key={col.name}
className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap" 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} {col.label}
</th> </th>
@ -5040,6 +5224,31 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div> </div>
)} )}
</CardContent> </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> </Card>
</div> </div>

View File

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

View File

@ -798,6 +798,25 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
minHeight: "48px", 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 && ( {activeFilters.length > 0 && (
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center"> <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 설정이 있는 경우 // 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) { if (targetType === "component" && targetComponentId) {
// 같은 화면 내 컴포넌트로 전달 + 레이어 활성화 이벤트 병행 // 같은 화면 내 컴포넌트로 전달 + 레이어 활성화 이벤트 병행

View File

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