Merge pull request 'jskim-node' (#419) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/419
This commit is contained in:
commit
176a2c2c37
|
|
@ -823,6 +823,76 @@ export class EntityJoinService {
|
|||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 콤마 구분 다중값 해결 (겸직 부서 등)
|
||||
* entity join이 NULL인데 소스값에 콤마가 있으면 개별 코드를 각각 조회해서 라벨로 변환
|
||||
*/
|
||||
async resolveCommaValues(
|
||||
data: Record<string, any>[],
|
||||
joinConfigs: EntityJoinConfig[]
|
||||
): Promise<Record<string, any>[]> {
|
||||
if (!data.length || !joinConfigs.length) return data;
|
||||
|
||||
for (const config of joinConfigs) {
|
||||
const sourceCol = config.sourceColumn;
|
||||
const displayCol = config.displayColumns?.[0] || config.displayColumn;
|
||||
if (!displayCol || displayCol === "none") continue;
|
||||
|
||||
const aliasCol = config.aliasColumn || `${sourceCol}_${displayCol}`;
|
||||
const labelCol = `${sourceCol}_label`;
|
||||
|
||||
const codesSet = new Set<string>();
|
||||
const rowsToResolve: number[] = [];
|
||||
|
||||
data.forEach((row, idx) => {
|
||||
const srcVal = row[sourceCol];
|
||||
if (!srcVal || typeof srcVal !== "string" || !srcVal.includes(",")) return;
|
||||
|
||||
const joinedVal = row[aliasCol] || row[labelCol];
|
||||
if (joinedVal && joinedVal !== "") return;
|
||||
|
||||
rowsToResolve.push(idx);
|
||||
srcVal.split(",").map((v: string) => v.trim()).filter(Boolean).forEach((code: string) => codesSet.add(code));
|
||||
});
|
||||
|
||||
if (codesSet.size === 0) continue;
|
||||
|
||||
const codes = Array.from(codesSet);
|
||||
const refCol = config.referenceColumn || "id";
|
||||
const placeholders = codes.map((_, i) => `$${i + 1}`).join(",");
|
||||
try {
|
||||
const result = await query<Record<string, any>>(
|
||||
`SELECT "${refCol}"::TEXT as _key, "${displayCol}"::TEXT as _label
|
||||
FROM ${config.referenceTable}
|
||||
WHERE "${refCol}"::TEXT IN (${placeholders})`,
|
||||
codes
|
||||
);
|
||||
|
||||
const labelMap = new Map<string, string>();
|
||||
result.forEach((r) => labelMap.set(r._key, r._label));
|
||||
|
||||
for (const idx of rowsToResolve) {
|
||||
const srcVal = data[idx][sourceCol] as string;
|
||||
const resolvedLabels = srcVal
|
||||
.split(",")
|
||||
.map((v: string) => v.trim())
|
||||
.filter(Boolean)
|
||||
.map((code: string) => labelMap.get(code) || code)
|
||||
.join(", ");
|
||||
|
||||
data[idx][aliasCol] = resolvedLabels;
|
||||
data[idx][labelCol] = resolvedLabels;
|
||||
}
|
||||
|
||||
logger.info(`콤마 구분 entity 값 해결: ${sourceCol} → ${codesSet.size}개 코드, ${rowsToResolve.length}개 행`);
|
||||
} catch (err) {
|
||||
logger.warn(`콤마 구분 entity 값 해결 실패: ${sourceCol}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export const entityJoinService = new EntityJoinService();
|
||||
|
|
|
|||
|
|
@ -235,6 +235,312 @@ class NumberingRuleService {
|
|||
);
|
||||
return result.rows[0].current_sequence;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카운터를 특정 값 이상으로 동기화 (GREATEST 사용)
|
||||
* 테이블 내 실제 최대값이 카운터보다 높을 때 카운터를 맞춰줌
|
||||
*/
|
||||
private async setSequenceForPrefix(
|
||||
client: any,
|
||||
ruleId: string,
|
||||
companyCode: string,
|
||||
prefixKey: string,
|
||||
targetSequence: number
|
||||
): Promise<number> {
|
||||
const result = await client.query(
|
||||
`INSERT INTO numbering_rule_sequences (rule_id, company_code, prefix_key, current_sequence, last_allocated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (rule_id, company_code, prefix_key)
|
||||
DO UPDATE SET current_sequence = GREATEST(numbering_rule_sequences.current_sequence, $4),
|
||||
last_allocated_at = NOW()
|
||||
RETURNING current_sequence`,
|
||||
[ruleId, companyCode, prefixKey, targetSequence]
|
||||
);
|
||||
return result.rows[0].current_sequence;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 테이블에서 해당 회사의 최대 시퀀스 번호를 조회
|
||||
* 코드의 prefix/suffix 패턴을 기반으로 sequence 부분만 추출하여 MAX 계산
|
||||
*/
|
||||
private async getMaxSequenceFromTable(
|
||||
client: any,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
codePrefix: string,
|
||||
codeSuffix: string,
|
||||
seqLength: number,
|
||||
companyCode: string
|
||||
): Promise<number> {
|
||||
try {
|
||||
// 테이블에 company_code 컬럼이 있는지 확인
|
||||
const colCheck = await client.query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[tableName]
|
||||
);
|
||||
const hasCompanyCode = colCheck.rows.length > 0;
|
||||
|
||||
// 대상 컬럼 존재 여부 확인
|
||||
const targetColCheck = await client.query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = $2`,
|
||||
[tableName, columnName]
|
||||
);
|
||||
if (targetColCheck.rows.length === 0) {
|
||||
logger.warn(`getMaxSequenceFromTable: 컬럼 없음 ${tableName}.${columnName}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// prefix와 suffix 사이의 sequence 부분을 추출하기 위한 위치 계산
|
||||
const prefixLen = codePrefix.length;
|
||||
const seqStart = prefixLen + 1; // SQL SUBSTRING은 1-based
|
||||
|
||||
// LIKE 패턴: prefix + N자리 숫자 + suffix
|
||||
const likePattern = codePrefix + "%" + codeSuffix;
|
||||
|
||||
let sql: string;
|
||||
let params: any[];
|
||||
|
||||
if (hasCompanyCode && companyCode !== "*") {
|
||||
sql = `
|
||||
SELECT MAX(
|
||||
CAST(SUBSTRING("${columnName}" FROM $1 FOR $2) AS INTEGER)
|
||||
) as max_seq
|
||||
FROM "${tableName}"
|
||||
WHERE "${columnName}" LIKE $3
|
||||
AND company_code = $4
|
||||
AND LENGTH("${columnName}") = $5
|
||||
AND SUBSTRING("${columnName}" FROM $1 FOR $2) ~ '^[0-9]+$'
|
||||
`;
|
||||
params = [seqStart, seqLength, likePattern, companyCode, prefixLen + seqLength + codeSuffix.length];
|
||||
} else {
|
||||
sql = `
|
||||
SELECT MAX(
|
||||
CAST(SUBSTRING("${columnName}" FROM $1 FOR $2) AS INTEGER)
|
||||
) as max_seq
|
||||
FROM "${tableName}"
|
||||
WHERE "${columnName}" LIKE $3
|
||||
AND LENGTH("${columnName}") = $4
|
||||
AND SUBSTRING("${columnName}" FROM $1 FOR $2) ~ '^[0-9]+$'
|
||||
`;
|
||||
params = [seqStart, seqLength, likePattern, prefixLen + seqLength + codeSuffix.length];
|
||||
}
|
||||
|
||||
const result = await client.query(sql, params);
|
||||
const maxSeq = result.rows[0]?.max_seq ?? 0;
|
||||
|
||||
logger.info("getMaxSequenceFromTable 결과", {
|
||||
tableName, columnName, codePrefix, codeSuffix,
|
||||
seqLength, companyCode, maxSeq,
|
||||
});
|
||||
|
||||
return maxSeq;
|
||||
} catch (error: any) {
|
||||
logger.warn("getMaxSequenceFromTable 실패 (카운터 폴백)", {
|
||||
tableName, columnName, error: error.message,
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 규칙의 파트 구성에서 sequence 파트 전후의 prefix/suffix를 계산
|
||||
* allocateCode/previewCode에서 비-sequence 파트 값이 이미 계산된 후 호출
|
||||
*/
|
||||
private buildCodePrefixSuffix(
|
||||
partValues: string[],
|
||||
sortedParts: any[],
|
||||
globalSeparator: string
|
||||
): { prefix: string; suffix: string; seqIndex: number; seqLength: number } | null {
|
||||
const seqIndex = sortedParts.findIndex((p: any) => p.partType === "sequence");
|
||||
if (seqIndex === -1) return null;
|
||||
|
||||
const seqLength = sortedParts[seqIndex].autoConfig?.sequenceLength || 3;
|
||||
|
||||
// prefix: sequence 파트 이전의 모든 파트값 + 구분자
|
||||
let prefix = "";
|
||||
for (let i = 0; i < seqIndex; i++) {
|
||||
prefix += partValues[i];
|
||||
const sep = sortedParts[i].separatorAfter ?? sortedParts[i].autoConfig?.separatorAfter ?? globalSeparator;
|
||||
prefix += sep;
|
||||
}
|
||||
|
||||
// suffix: sequence 파트 이후의 모든 파트값 + 구분자
|
||||
let suffix = "";
|
||||
for (let i = seqIndex + 1; i < partValues.length; i++) {
|
||||
const sep = sortedParts[i - 1].separatorAfter ?? sortedParts[i - 1].autoConfig?.separatorAfter ?? globalSeparator;
|
||||
if (i === seqIndex + 1) {
|
||||
// sequence 파트 바로 뒤 구분자
|
||||
const seqSep = sortedParts[seqIndex].separatorAfter ?? sortedParts[seqIndex].autoConfig?.separatorAfter ?? globalSeparator;
|
||||
suffix += seqSep;
|
||||
}
|
||||
suffix += partValues[i];
|
||||
if (i < partValues.length - 1) {
|
||||
const nextSep = sortedParts[i].separatorAfter ?? sortedParts[i].autoConfig?.separatorAfter ?? globalSeparator;
|
||||
suffix += nextSep;
|
||||
}
|
||||
}
|
||||
|
||||
return { prefix, suffix, seqIndex, seqLength };
|
||||
}
|
||||
|
||||
/**
|
||||
* 비-sequence 파트의 값을 계산하여 prefix/suffix 패턴 구축에 사용
|
||||
* sequence 파트는 빈 문자열로 반환 (이후 buildCodePrefixSuffix에서 처리)
|
||||
*/
|
||||
private async computeNonSequenceValues(
|
||||
rule: NumberingRuleConfig,
|
||||
formData?: Record<string, any>
|
||||
): Promise<string[]> {
|
||||
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
||||
return Promise.all(sortedParts.map(async (part: any) => {
|
||||
if (part.partType === "sequence") return "";
|
||||
if (part.generationMethod === "manual") return "";
|
||||
|
||||
const autoConfig = part.autoConfig || {};
|
||||
|
||||
switch (part.partType) {
|
||||
case "text":
|
||||
return autoConfig.textValue || "TEXT";
|
||||
|
||||
case "number": {
|
||||
const length = autoConfig.numberLength || 3;
|
||||
const value = autoConfig.numberValue || 1;
|
||||
return String(value).padStart(length, "0");
|
||||
}
|
||||
|
||||
case "date": {
|
||||
const dateFormat = autoConfig.dateFormat || "YYYYMMDD";
|
||||
if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) {
|
||||
const columnValue = formData[autoConfig.sourceColumnName];
|
||||
if (columnValue) {
|
||||
const dateValue = columnValue instanceof Date ? columnValue : new Date(columnValue);
|
||||
if (!isNaN(dateValue.getTime())) {
|
||||
return this.formatDate(dateValue, dateFormat);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.formatDate(new Date(), dateFormat);
|
||||
}
|
||||
|
||||
case "category": {
|
||||
const categoryKey = autoConfig.categoryKey;
|
||||
const categoryMappings = autoConfig.categoryMappings || [];
|
||||
if (!categoryKey || !formData) return "";
|
||||
|
||||
const colName = categoryKey.includes(".") ? categoryKey.split(".")[1] : categoryKey;
|
||||
const selectedValue = formData[colName];
|
||||
if (!selectedValue) return "";
|
||||
|
||||
const selectedValueStr = String(selectedValue);
|
||||
let mapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === selectedValueStr) return true;
|
||||
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!mapping) {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const [ct, cc] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey];
|
||||
const cvResult = await pool.query(
|
||||
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
||||
[ct, cc, selectedValueStr]
|
||||
);
|
||||
if (cvResult.rows.length > 0) {
|
||||
mapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === String(cvResult.rows[0].value_id)) return true;
|
||||
if (m.categoryValueLabel === cvResult.rows[0].value_label) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return mapping?.format || "";
|
||||
}
|
||||
|
||||
case "reference": {
|
||||
const refColumn = autoConfig.referenceColumnName;
|
||||
if (refColumn && formData && formData[refColumn]) {
|
||||
return String(formData[refColumn]);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 테이블 기반으로 실제 최대 시퀀스를 확인하고,
|
||||
* 카운터와 비교하여 더 높은 값 + 1을 반환
|
||||
*/
|
||||
private async resolveNextSequence(
|
||||
client: any,
|
||||
rule: NumberingRuleConfig,
|
||||
companyCode: string,
|
||||
ruleId: string,
|
||||
prefixKey: string,
|
||||
formData?: Record<string, any>
|
||||
): Promise<number> {
|
||||
// 1. 현재 저장된 카운터 조회
|
||||
const currentCounter = await this.getSequenceForPrefix(
|
||||
client, ruleId, companyCode, prefixKey
|
||||
);
|
||||
|
||||
let baseSequence = currentCounter;
|
||||
|
||||
// 2. 규칙에 tableName/columnName이 설정되어 있으면 대상 테이블에서 MAX 조회
|
||||
if (rule.tableName && rule.columnName) {
|
||||
try {
|
||||
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
||||
const patternValues = await this.computeNonSequenceValues(rule, formData);
|
||||
const psInfo = this.buildCodePrefixSuffix(patternValues, sortedParts, rule.separator || "");
|
||||
|
||||
if (psInfo) {
|
||||
const maxFromTable = await this.getMaxSequenceFromTable(
|
||||
client, rule.tableName, rule.columnName,
|
||||
psInfo.prefix, psInfo.suffix, psInfo.seqLength, companyCode
|
||||
);
|
||||
|
||||
if (maxFromTable > baseSequence) {
|
||||
logger.info("테이블 내 최대값이 카운터보다 높음 → 동기화", {
|
||||
ruleId, companyCode, currentCounter, maxFromTable,
|
||||
});
|
||||
baseSequence = maxFromTable;
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn("테이블 기반 MAX 조회 실패, 카운터 기반 폴백", {
|
||||
ruleId, error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 다음 시퀀스 = base + 1
|
||||
const nextSequence = baseSequence + 1;
|
||||
|
||||
// 4. 카운터를 동기화 (GREATEST 사용)
|
||||
await this.setSequenceForPrefix(client, ruleId, companyCode, prefixKey, nextSequence);
|
||||
|
||||
// 5. 호환성을 위해 numbering_rules.current_sequence도 업데이트
|
||||
await client.query(
|
||||
"UPDATE numbering_rules SET current_sequence = GREATEST(COALESCE(current_sequence, 0), $3) WHERE rule_id = $1 AND company_code = $2",
|
||||
[ruleId, companyCode, nextSequence]
|
||||
);
|
||||
|
||||
logger.info("resolveNextSequence 완료", {
|
||||
ruleId, companyCode, prefixKey, currentCounter, baseSequence, nextSequence,
|
||||
});
|
||||
|
||||
return nextSequence;
|
||||
}
|
||||
|
||||
/**
|
||||
* 규칙 목록 조회 (전체)
|
||||
*/
|
||||
|
|
@ -1101,6 +1407,7 @@ class NumberingRuleService {
|
|||
const hasManualPart = rule.parts.some((p: any) => p.generationMethod === "manual");
|
||||
const skipSequenceLookup = hasManualPart && !manualInputValue;
|
||||
|
||||
// prefix_key 기반 순번 조회 + 테이블 내 최대값과 비교
|
||||
const manualValues = manualInputValue ? [manualInputValue] : undefined;
|
||||
const prefixKey = await this.buildPrefixKey(rule, formData, manualValues);
|
||||
const pool = getPool();
|
||||
|
|
@ -1108,8 +1415,36 @@ class NumberingRuleService {
|
|||
? 0
|
||||
: await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
|
||||
|
||||
logger.info("미리보기: prefix_key 기반 순번 조회", {
|
||||
ruleId, prefixKey, currentSeq, skipSequenceLookup,
|
||||
// 대상 테이블에서 실제 최대 시퀀스 조회
|
||||
let baseSeq = currentSeq;
|
||||
if (rule.tableName && rule.columnName) {
|
||||
try {
|
||||
const sortedPartsForPattern = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
||||
const patternValues = await this.computeNonSequenceValues(rule, formData);
|
||||
const psInfo = this.buildCodePrefixSuffix(patternValues, sortedPartsForPattern, rule.separator || "");
|
||||
|
||||
if (psInfo) {
|
||||
const maxFromTable = await this.getMaxSequenceFromTable(
|
||||
pool, rule.tableName, rule.columnName,
|
||||
psInfo.prefix, psInfo.suffix, psInfo.seqLength, companyCode
|
||||
);
|
||||
|
||||
if (maxFromTable > baseSeq) {
|
||||
logger.info("미리보기: 테이블 내 최대값이 카운터보다 높음", {
|
||||
ruleId, companyCode, currentSeq, maxFromTable,
|
||||
});
|
||||
baseSeq = maxFromTable;
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn("미리보기: 테이블 기반 MAX 조회 실패, 카운터 기반 폴백", {
|
||||
ruleId, error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("미리보기: 순번 조회 완료", {
|
||||
ruleId, prefixKey, currentSeq, baseSeq, skipSequenceLookup,
|
||||
});
|
||||
|
||||
const parts = await Promise.all(rule.parts
|
||||
|
|
@ -1125,7 +1460,7 @@ class NumberingRuleService {
|
|||
case "sequence": {
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
const startFrom = autoConfig.startFrom || 1;
|
||||
const nextSequence = currentSeq + startFrom;
|
||||
const nextSequence = baseSeq + startFrom;
|
||||
return String(nextSequence).padStart(length, "0");
|
||||
}
|
||||
|
||||
|
|
@ -1239,20 +1574,15 @@ class NumberingRuleService {
|
|||
const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues);
|
||||
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
|
||||
|
||||
// 3단계: 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득
|
||||
// 3단계: 순번이 있으면 prefix_key 기반 UPSERT + 테이블 내 최대값 비교하여 다음 순번 결정
|
||||
let allocatedSequence = 0;
|
||||
if (hasSequence) {
|
||||
allocatedSequence = await this.incrementSequenceForPrefix(
|
||||
client, ruleId, companyCode, prefixKey
|
||||
);
|
||||
// 호환성을 위해 기존 current_sequence도 업데이트
|
||||
await client.query(
|
||||
"UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2",
|
||||
[ruleId, companyCode]
|
||||
allocatedSequence = await this.resolveNextSequence(
|
||||
client, rule, companyCode, ruleId, prefixKey, formData
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("allocateCode: prefix_key 기반 순번 할당", {
|
||||
logger.info("allocateCode: prefix_key + 테이블 기반 순번 할당", {
|
||||
ruleId, prefixKey, allocatedSequence, extractedManualValues,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3588,12 +3588,15 @@ export class TableManagementService {
|
|||
`✅ [executeJoinQuery] 조회 완료: ${dataResult?.length}개 행`
|
||||
);
|
||||
|
||||
const data = Array.isArray(dataResult) ? dataResult : [];
|
||||
let data = Array.isArray(dataResult) ? dataResult : [];
|
||||
const total =
|
||||
Array.isArray(countResult) && countResult.length > 0
|
||||
? Number((countResult[0] as any).total)
|
||||
: 0;
|
||||
|
||||
// 콤마 구분 다중값 후처리 (겸직 부서 등)
|
||||
data = await entityJoinService.resolveCommaValues(data, joinConfigs);
|
||||
|
||||
const queryTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -1338,7 +1338,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
size: {
|
||||
...splitAdjustedComponent.size,
|
||||
width: undefined as unknown as number,
|
||||
height: undefined as unknown as number,
|
||||
},
|
||||
} : {}),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -577,7 +577,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
// - 버튼 컴포넌트: buttonElementStyle에서 자체 border 적용
|
||||
const isV2HorizLabel = !!(
|
||||
componentStyle &&
|
||||
(componentStyle.labelDisplay === true || componentStyle.labelDisplay === "true") &&
|
||||
componentStyle.labelDisplay !== false && componentStyle.labelDisplay !== "false" &&
|
||||
(componentStyle.labelPosition === "left" || componentStyle.labelPosition === "right")
|
||||
);
|
||||
const needsStripBorder = isV2HorizLabel || isButtonComponent;
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@
|
|||
|
||||
import React, { useRef, useState, useEffect } from "react";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
import { useResponsive } from "@/lib/hooks/useResponsive";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ResponsiveGridRendererProps {
|
||||
components: ComponentData[];
|
||||
|
|
@ -12,60 +10,6 @@ interface ResponsiveGridRendererProps {
|
|||
renderComponent: (component: ComponentData) => React.ReactNode;
|
||||
}
|
||||
|
||||
const FULL_WIDTH_TYPES = new Set([
|
||||
"table-list",
|
||||
"v2-table-list",
|
||||
"table-search-widget",
|
||||
"v2-table-search-widget",
|
||||
"conditional-container",
|
||||
"split-panel-layout",
|
||||
"split-panel-layout2",
|
||||
"v2-split-panel-layout",
|
||||
"screen-split-panel",
|
||||
"v2-split-line",
|
||||
"flow-widget",
|
||||
"v2-tab-container",
|
||||
"tab-container",
|
||||
"tabs-widget",
|
||||
"v2-tabs-widget",
|
||||
]);
|
||||
|
||||
const FLEX_GROW_TYPES = new Set([
|
||||
"table-list",
|
||||
"v2-table-list",
|
||||
"split-panel-layout",
|
||||
"split-panel-layout2",
|
||||
"v2-split-panel-layout",
|
||||
"screen-split-panel",
|
||||
"v2-tab-container",
|
||||
"tab-container",
|
||||
"tabs-widget",
|
||||
"v2-tabs-widget",
|
||||
]);
|
||||
|
||||
function groupComponentsIntoRows(
|
||||
components: ComponentData[],
|
||||
threshold: number = 30
|
||||
): ComponentData[][] {
|
||||
if (components.length === 0) return [];
|
||||
const sorted = [...components].sort((a, b) => a.position.y - b.position.y);
|
||||
const rows: ComponentData[][] = [];
|
||||
let currentRow: ComponentData[] = [];
|
||||
let currentRowY = -Infinity;
|
||||
|
||||
for (const comp of sorted) {
|
||||
if (comp.position.y - currentRowY > threshold) {
|
||||
if (currentRow.length > 0) rows.push(currentRow);
|
||||
currentRow = [comp];
|
||||
currentRowY = comp.position.y;
|
||||
} else {
|
||||
currentRow.push(comp);
|
||||
}
|
||||
}
|
||||
if (currentRow.length > 0) rows.push(currentRow);
|
||||
return rows.map((row) => row.sort((a, b) => a.position.x - b.position.x));
|
||||
}
|
||||
|
||||
function getComponentTypeId(component: ComponentData): string {
|
||||
const direct =
|
||||
(component as any).componentType || (component as any).widgetType;
|
||||
|
|
@ -78,132 +22,10 @@ function getComponentTypeId(component: ComponentData): string {
|
|||
return component.type || "";
|
||||
}
|
||||
|
||||
function isButtonComponent(component: ComponentData): boolean {
|
||||
return getComponentTypeId(component).includes("button");
|
||||
}
|
||||
|
||||
function isFullWidthComponent(component: ComponentData): boolean {
|
||||
return FULL_WIDTH_TYPES.has(getComponentTypeId(component));
|
||||
}
|
||||
|
||||
function shouldFlexGrow(component: ComponentData): boolean {
|
||||
return FLEX_GROW_TYPES.has(getComponentTypeId(component));
|
||||
}
|
||||
|
||||
function getPercentageWidth(componentWidth: number, canvasWidth: number): number {
|
||||
const pct = (componentWidth / canvasWidth) * 100;
|
||||
return pct >= 95 ? 100 : pct;
|
||||
}
|
||||
|
||||
function getRowGap(row: ComponentData[], canvasWidth: number): number {
|
||||
if (row.length < 2) return 0;
|
||||
const totalW = row.reduce((s, c) => s + (c.size?.width || 100), 0);
|
||||
const gap = canvasWidth - totalW;
|
||||
const cnt = row.length - 1;
|
||||
if (gap <= 0 || cnt <= 0) return 8;
|
||||
return Math.min(Math.max(Math.round(gap / cnt), 4), 24);
|
||||
}
|
||||
|
||||
interface ProcessedRow {
|
||||
type: "normal" | "fullwidth";
|
||||
mainComponent?: ComponentData;
|
||||
overlayComps: ComponentData[];
|
||||
normalComps: ComponentData[];
|
||||
rowMinY?: number;
|
||||
rowMaxBottom?: number;
|
||||
}
|
||||
|
||||
function FullWidthOverlayRow({
|
||||
main,
|
||||
overlayComps,
|
||||
canvasWidth,
|
||||
renderComponent,
|
||||
}: {
|
||||
main: ComponentData;
|
||||
overlayComps: ComponentData[];
|
||||
canvasWidth: number;
|
||||
renderComponent: (component: ComponentData) => React.ReactNode;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerW, setContainerW] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const w = entries[0]?.contentRect.width;
|
||||
if (w && w > 0) setContainerW(w);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const compFlexGrow = shouldFlexGrow(main);
|
||||
const mainY = main.position.y;
|
||||
const scale = containerW > 0 ? containerW / canvasWidth : 1;
|
||||
|
||||
const minButtonY = Math.min(...overlayComps.map((c) => c.position.y));
|
||||
const rawYOffset = minButtonY - mainY;
|
||||
const maxBtnH = Math.max(
|
||||
...overlayComps.map((c) => c.size?.height || 40)
|
||||
);
|
||||
const yOffset = rawYOffset + (maxBtnH / 2) * (1 - scale);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative flex w-full flex-col",
|
||||
compFlexGrow ? "min-h-0 flex-1" : "flex-shrink-0"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-component-id={main.id}
|
||||
data-component-type={getComponentTypeId(main)}
|
||||
className="min-h-0 min-w-0"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: compFlexGrow ? "100%" : "auto",
|
||||
minHeight: compFlexGrow ? "300px" : undefined,
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
{renderComponent(main)}
|
||||
</div>
|
||||
|
||||
{overlayComps.length > 0 && containerW > 0 && (
|
||||
<div
|
||||
className="pointer-events-none absolute left-0 z-10"
|
||||
style={{
|
||||
top: `${yOffset}px`,
|
||||
width: `${canvasWidth}px`,
|
||||
height: `${maxBtnH}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
>
|
||||
{overlayComps.map((comp) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
data-component-id={comp.id}
|
||||
data-component-type={getComponentTypeId(comp)}
|
||||
className="pointer-events-auto absolute"
|
||||
style={{
|
||||
left: `${comp.position.x}px`,
|
||||
top: `${comp.position.y - minButtonY}px`,
|
||||
width: `${comp.size?.width || 90}px`,
|
||||
height: `${comp.size?.height || 40}px`,
|
||||
}}
|
||||
>
|
||||
{renderComponent(comp)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 디자이너 절대좌표를 캔버스 대비 비율로 변환하여 렌더링.
|
||||
* 화면이 줄어들면 비율에 맞게 축소, 늘어나면 확대.
|
||||
*/
|
||||
function ProportionalRenderer({
|
||||
components,
|
||||
canvasWidth,
|
||||
|
|
@ -270,220 +92,13 @@ export function ResponsiveGridRenderer({
|
|||
canvasHeight,
|
||||
renderComponent,
|
||||
}: ResponsiveGridRendererProps) {
|
||||
const { isMobile } = useResponsive();
|
||||
|
||||
const topLevel = components.filter((c) => !c.parentId);
|
||||
const hasFullWidthComponent = topLevel.some((c) => isFullWidthComponent(c));
|
||||
|
||||
if (!isMobile && !hasFullWidthComponent) {
|
||||
return (
|
||||
<ProportionalRenderer
|
||||
components={components}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
renderComponent={renderComponent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = groupComponentsIntoRows(topLevel);
|
||||
const processedRows: ProcessedRow[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const fullWidthComps: ComponentData[] = [];
|
||||
const normalComps: ComponentData[] = [];
|
||||
|
||||
for (const comp of row) {
|
||||
if (isFullWidthComponent(comp)) {
|
||||
fullWidthComps.push(comp);
|
||||
} else {
|
||||
normalComps.push(comp);
|
||||
}
|
||||
}
|
||||
|
||||
const allComps = [...fullWidthComps, ...normalComps];
|
||||
const rowMinY = allComps.length > 0 ? Math.min(...allComps.map(c => c.position.y)) : 0;
|
||||
const rowMaxBottom = allComps.length > 0 ? Math.max(...allComps.map(c => c.position.y + (c.size?.height || 40))) : 0;
|
||||
|
||||
if (fullWidthComps.length > 0 && normalComps.length > 0) {
|
||||
for (const fwComp of fullWidthComps) {
|
||||
processedRows.push({
|
||||
type: "fullwidth",
|
||||
mainComponent: fwComp,
|
||||
overlayComps: normalComps,
|
||||
normalComps: [],
|
||||
rowMinY,
|
||||
rowMaxBottom,
|
||||
});
|
||||
}
|
||||
} else if (fullWidthComps.length > 0) {
|
||||
for (const fwComp of fullWidthComps) {
|
||||
processedRows.push({
|
||||
type: "fullwidth",
|
||||
mainComponent: fwComp,
|
||||
overlayComps: [],
|
||||
normalComps: [],
|
||||
rowMinY,
|
||||
rowMaxBottom,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
processedRows.push({
|
||||
type: "normal",
|
||||
overlayComps: [],
|
||||
normalComps,
|
||||
rowMinY,
|
||||
rowMaxBottom,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-screen-runtime="true"
|
||||
className="bg-background flex h-full w-full flex-col overflow-x-hidden"
|
||||
style={{ minHeight: "200px" }}
|
||||
>
|
||||
{processedRows.map((processedRow, rowIndex) => {
|
||||
const rowMarginTop = (() => {
|
||||
if (rowIndex === 0) return 0;
|
||||
const prevRow = processedRows[rowIndex - 1];
|
||||
const prevBottom = prevRow.rowMaxBottom ?? 0;
|
||||
const currTop = processedRow.rowMinY ?? 0;
|
||||
const designGap = currTop - prevBottom;
|
||||
if (designGap <= 0) return 0;
|
||||
return Math.min(Math.max(Math.round(designGap * 0.5), 4), 48);
|
||||
})();
|
||||
|
||||
if (processedRow.type === "fullwidth" && processedRow.mainComponent) {
|
||||
return (
|
||||
<div key={`row-${rowIndex}`} style={{ marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }}>
|
||||
<FullWidthOverlayRow
|
||||
main={processedRow.mainComponent}
|
||||
overlayComps={processedRow.overlayComps}
|
||||
canvasWidth={canvasWidth}
|
||||
renderComponent={renderComponent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { normalComps } = processedRow;
|
||||
const allButtons = normalComps.every((c) => isButtonComponent(c));
|
||||
|
||||
// 데스크톱에서 버튼만 있는 행: 디자이너의 x, width를 비율로 적용
|
||||
if (allButtons && normalComps.length > 0 && !isMobile) {
|
||||
const rowHeight = Math.max(...normalComps.map(c => c.size?.height || 40));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`row-${rowIndex}`}
|
||||
className="relative w-full flex-shrink-0"
|
||||
style={{
|
||||
height: `${rowHeight}px`,
|
||||
marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined,
|
||||
}}
|
||||
>
|
||||
{normalComps.map((component) => {
|
||||
const typeId = getComponentTypeId(component);
|
||||
const leftPct = (component.position.x / canvasWidth) * 100;
|
||||
const widthPct = ((component.size?.width || 90) / canvasWidth) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={component.id}
|
||||
data-component-id={component.id}
|
||||
data-component-type={typeId}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${leftPct}%`,
|
||||
width: `${widthPct}%`,
|
||||
height: `${component.size?.height || 40}px`,
|
||||
}}
|
||||
>
|
||||
{renderComponent(component)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gap = isMobile ? 8 : getRowGap(normalComps, canvasWidth);
|
||||
|
||||
const hasFlexHeightComp = normalComps.some((c) => {
|
||||
const h = c.size?.height || 0;
|
||||
return h / canvasHeight >= 0.8;
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`row-${rowIndex}`}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap overflow-hidden",
|
||||
hasFlexHeightComp ? "min-h-0 flex-1" : "flex-shrink-0"
|
||||
)}
|
||||
style={{ gap: `${gap}px`, marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }}
|
||||
>
|
||||
{normalComps.map((component) => {
|
||||
const typeId = getComponentTypeId(component);
|
||||
const isButton = isButtonComponent(component);
|
||||
const isFullWidth = isMobile && !isButton;
|
||||
|
||||
if (isButton) {
|
||||
return (
|
||||
<div
|
||||
key={component.id}
|
||||
data-component-id={component.id}
|
||||
data-component-type={typeId}
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
height: component.size?.height
|
||||
? `${component.size.height}px`
|
||||
: "40px",
|
||||
}}
|
||||
>
|
||||
{renderComponent(component)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const percentWidth = isFullWidth
|
||||
? 100
|
||||
: getPercentageWidth(component.size?.width || 100, canvasWidth);
|
||||
const flexBasis = isFullWidth
|
||||
? "100%"
|
||||
: `calc(${percentWidth}% - ${gap}px)`;
|
||||
|
||||
const heightPct = (component.size?.height || 0) / canvasHeight;
|
||||
const useFlexHeight = heightPct >= 0.8;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={component.id}
|
||||
data-component-id={component.id}
|
||||
data-component-type={typeId}
|
||||
className={cn("min-w-0 overflow-hidden", useFlexHeight && "min-h-0 flex-1")}
|
||||
style={{
|
||||
width: isFullWidth ? "100%" : undefined,
|
||||
flexBasis: useFlexHeight ? undefined : flexBasis,
|
||||
flexGrow: percentWidth,
|
||||
flexShrink: 1,
|
||||
minWidth: isMobile ? "100%" : undefined,
|
||||
minHeight: useFlexHeight ? "300px" : (component.size?.height
|
||||
? `${component.size.height}px`
|
||||
: undefined),
|
||||
height: useFlexHeight ? "100%" : "auto",
|
||||
}}
|
||||
>
|
||||
{renderComponent(component)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ProportionalRenderer
|
||||
components={components}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
renderComponent={renderComponent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -240,14 +239,14 @@ export function ScreenSettingModal({
|
|||
componentCount = 0,
|
||||
onSaveSuccess,
|
||||
}: ScreenSettingModalProps) {
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dataFlows, setDataFlows] = useState<DataFlow[]>([]);
|
||||
const [layoutItems, setLayoutItems] = useState<LayoutItem[]>([]);
|
||||
const [iframeKey, setIframeKey] = useState(0); // iframe 새로고침용 키
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); // 화면 캔버스 크기
|
||||
const [showDesignerModal, setShowDesignerModal] = useState(false); // 화면 디자이너 모달
|
||||
const [showTableSettingModal, setShowTableSettingModal] = useState(false); // 테이블 설정 모달
|
||||
const [buttonControls, setButtonControls] = useState<ButtonControlInfo[]>([]);
|
||||
const [iframeKey, setIframeKey] = useState(0);
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
||||
const [showDesignerModal, setShowDesignerModal] = useState(false);
|
||||
const [showTableSettingModal, setShowTableSettingModal] = useState(false);
|
||||
const [tableSettingTarget, setTableSettingTarget] = useState<{ tableName: string; tableLabel?: string } | null>(null);
|
||||
|
||||
// 그룹 내 화면 목록 및 현재 선택된 화면
|
||||
|
|
@ -338,12 +337,56 @@ export function ScreenSettingModal({
|
|||
if (layoutResponse.success && layoutResponse.data) {
|
||||
const screenLayout = layoutResponse.data[currentScreenId];
|
||||
setLayoutItems(screenLayout?.layoutItems || []);
|
||||
// 캔버스 크기 저장 (화면 프리뷰에 사용)
|
||||
setCanvasSize({
|
||||
width: screenLayout?.canvasWidth || 0,
|
||||
height: screenLayout?.canvasHeight || 0,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 버튼 정보 추출 (읽기 전용 요약용)
|
||||
try {
|
||||
const rawLayout = await screenApi.getLayout(currentScreenId);
|
||||
if (rawLayout?.components) {
|
||||
const buttons: ButtonControlInfo[] = [];
|
||||
const extractButtons = (components: any[]) => {
|
||||
for (const comp of components) {
|
||||
const config = comp.componentConfig || {};
|
||||
const isButton =
|
||||
comp.widgetType === "button" || comp.webType === "button" ||
|
||||
comp.type === "button" || config.webType === "button" ||
|
||||
comp.componentType?.includes("button") || comp.componentKind?.includes("button");
|
||||
if (isButton) {
|
||||
const webTypeConfig = comp.webTypeConfig || {};
|
||||
const action = config.action || {};
|
||||
buttons.push({
|
||||
id: comp.id || comp.componentId || `btn-${buttons.length}`,
|
||||
label: config.text || comp.label || comp.title || comp.name || "버튼",
|
||||
actionType: typeof action === "string" ? action : (action.type || "custom"),
|
||||
confirmMessage: action.confirmationMessage || action.confirmMessage || config.confirmMessage,
|
||||
confirmationEnabled: action.confirmationEnabled ?? (!!action.confirmationMessage || !!action.confirmMessage),
|
||||
backgroundColor: webTypeConfig.backgroundColor || config.backgroundColor || comp.style?.backgroundColor,
|
||||
textColor: webTypeConfig.textColor || config.textColor || comp.style?.color,
|
||||
borderRadius: webTypeConfig.borderRadius || config.borderRadius || comp.style?.borderRadius,
|
||||
linkedFlows: webTypeConfig.dataflowConfig?.flowConfigs?.map((fc: any) => ({
|
||||
id: fc.flowId, name: fc.flowName, timing: fc.executionTiming || "after",
|
||||
})) || (webTypeConfig.dataflowConfig?.flowConfig ? [{
|
||||
id: webTypeConfig.dataflowConfig.flowConfig.flowId,
|
||||
name: webTypeConfig.dataflowConfig.flowConfig.flowName,
|
||||
timing: webTypeConfig.dataflowConfig.flowConfig.executionTiming || "after",
|
||||
}] : []),
|
||||
});
|
||||
}
|
||||
if (comp.children && Array.isArray(comp.children)) extractButtons(comp.children);
|
||||
if (comp.componentConfig?.children && Array.isArray(comp.componentConfig.children)) extractButtons(comp.componentConfig.children);
|
||||
if (comp.items && Array.isArray(comp.items)) extractButtons(comp.items);
|
||||
}
|
||||
};
|
||||
extractButtons(rawLayout.components);
|
||||
setButtonControls(buttons);
|
||||
}
|
||||
} catch (btnError) {
|
||||
console.error("버튼 정보 추출 실패:", btnError);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("데이터 로드 실패:", error);
|
||||
} finally {
|
||||
|
|
@ -360,162 +403,295 @@ export function ScreenSettingModal({
|
|||
// 새로고침 (데이터 + iframe)
|
||||
const handleRefresh = useCallback(() => {
|
||||
loadData();
|
||||
setIframeKey(prev => prev + 1); // iframe 새로고침
|
||||
setIframeKey(prev => prev + 1);
|
||||
}, [loadData]);
|
||||
|
||||
// 통계 계산
|
||||
const stats = useMemo(() => {
|
||||
const totalJoins = filterTables.reduce((sum, ft) => sum + (ft.joinColumnRefs?.length || 0), 0);
|
||||
const layoutColumnsSet = new Set<string>();
|
||||
layoutItems.forEach((item) => {
|
||||
if (item.usedColumns) item.usedColumns.forEach((col) => layoutColumnsSet.add(col));
|
||||
if (item.bindField) layoutColumnsSet.add(item.bindField);
|
||||
});
|
||||
const inputCount = layoutItems.filter(i => !i.widgetType?.includes("button") && !i.componentKind?.includes("table")).length;
|
||||
const gridCount = layoutItems.filter(i => i.componentKind?.includes("table") || i.componentKind?.includes("grid")).length;
|
||||
return {
|
||||
tableCount: 1 + filterTables.length,
|
||||
fieldCount: layoutColumnsSet.size || fieldMappings.length,
|
||||
joinCount: totalJoins,
|
||||
flowCount: dataFlows.length,
|
||||
inputCount,
|
||||
gridCount,
|
||||
buttonCount: buttonControls.length,
|
||||
};
|
||||
}, [filterTables, fieldMappings, dataFlows, layoutItems, buttonControls]);
|
||||
|
||||
// 연결된 플로우 총 개수
|
||||
const linkedFlowCount = useMemo(() => {
|
||||
return buttonControls.reduce((sum, btn) => sum + (btn.linkedFlows?.length || 0), 0);
|
||||
}, [buttonControls]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen && !showDesignerModal} onOpenChange={onClose}>
|
||||
<DialogContent className="flex h-[90vh] max-h-[950px] w-[98vw] max-w-[1600px] flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-lg">
|
||||
<Settings2 className="h-5 w-5 text-primary" />
|
||||
화면 설정:
|
||||
{groupScreens.length > 1 ? (
|
||||
<Select
|
||||
value={currentScreenId.toString()}
|
||||
onValueChange={(value) => handleScreenChange(parseInt(value, 10))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-auto min-w-[200px] max-w-[400px] text-base font-semibold">
|
||||
<SelectValue placeholder="화면 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupScreens.map((screen) => (
|
||||
<SelectItem key={screen.screen_id} value={screen.screen_id.toString()}>
|
||||
{screen.screen_name}
|
||||
{screen.screen_role && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({screen.screen_role})
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<span>{currentScreenName}</span>
|
||||
<DialogContent className="flex h-[90vh] max-h-[950px] w-[98vw] max-w-[1600px] flex-col overflow-hidden border-border/40 bg-background/95 backdrop-blur-xl">
|
||||
{/* V3 Header */}
|
||||
<DialogHeader className="flex-shrink-0 pb-0">
|
||||
<DialogTitle className="flex items-center gap-3 text-base">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]" />
|
||||
<span className="font-bold tracking-tight">{currentScreenName}</span>
|
||||
{groupScreens.length > 1 && (
|
||||
<>
|
||||
<span className="h-3.5 w-px bg-border" />
|
||||
<Select
|
||||
value={currentScreenId.toString()}
|
||||
onValueChange={(value) => handleScreenChange(parseInt(value, 10))}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-auto min-w-[140px] max-w-[280px] border-border/40 bg-muted/30 text-xs font-medium">
|
||||
<SelectValue placeholder="화면 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupScreens.map((screen) => (
|
||||
<SelectItem key={screen.screen_id} value={screen.screen_id.toString()}>
|
||||
{screen.screen_name}
|
||||
{screen.screen_role && (
|
||||
<span className="ml-1 text-muted-foreground">({screen.screen_role})</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto font-mono text-[10px] text-muted-foreground/60">#{currentScreenId}</span>
|
||||
<Button variant="ghost" size="sm" onClick={handleRefresh} className="h-7 w-7 p-0" title="새로고침">
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
|
||||
</Button>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
화면의 필드 매핑, 테이블 연결, 데이터 흐름을 확인하고 설정합니다.
|
||||
</DialogDescription>
|
||||
<DialogDescription className="sr-only">화면 정보 패널</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 2컬럼 레이아웃: 왼쪽 탭(좁게) + 오른쪽 프리뷰(넓게) */}
|
||||
<div className="flex min-h-0 flex-1 gap-3">
|
||||
{/* 왼쪽: 탭 컨텐츠 (40%) */}
|
||||
<div className="flex min-h-0 w-[40%] flex-col rounded-lg border bg-card">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b p-2">
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="overview" className="gap-1 text-xs px-2">
|
||||
<Database className="h-3 w-3" />
|
||||
개요
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="table-setting" className="gap-1 text-xs px-2" disabled={!mainTable}>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
테이블 설정
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="control-management" className="gap-1 text-xs px-2">
|
||||
<Zap className="h-3 w-3" />
|
||||
제어 관리
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="data-flow" className="gap-1 text-xs px-2">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
데이터 흐름
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
className="h-7 w-7 p-0"
|
||||
title="새로고침"
|
||||
>
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowDesignerModal(true)}
|
||||
className="h-7 px-2 text-xs gap-1"
|
||||
title="화면 디자이너에서 상세 편집"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
디자이너
|
||||
</Button>
|
||||
{/* V3 Body: Left Info Panel + Right Preview */}
|
||||
<div className="flex min-h-0 flex-1 gap-3 pt-2">
|
||||
{/* 왼쪽: 정보 패널 (탭 없음, 단일 스크롤) */}
|
||||
<div className="flex min-h-0 w-[380px] flex-shrink-0 flex-col rounded-lg border border-border/40">
|
||||
<div className="flex-1 overflow-y-auto p-4 [&::-webkit-scrollbar]:w-[2px] [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/50">
|
||||
|
||||
{/* 1. 내러티브 요약 */}
|
||||
<div className="mb-4 rounded-lg border border-primary/10 bg-gradient-to-br from-primary/[0.04] to-blue-500/[0.02] p-3">
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">{currentMainTable || "테이블 미연결"}</span>
|
||||
{stats.fieldCount > 0 && <> 테이블의 <span className="font-bold text-primary">{stats.fieldCount}개</span> 컬럼을 사용하고 있어요.</>}
|
||||
{filterTables.length > 0 && <><br />필터 테이블 {filterTables.length}개{stats.joinCount > 0 && <>, 엔티티 조인 {stats.joinCount}개</>}가 연결되어 있어요.</>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 2. 속성 테이블 */}
|
||||
<div className="mb-4 space-y-0.5">
|
||||
<div className="flex h-[30px] items-center rounded-md px-2 text-[11px] transition-colors hover:bg-muted/30">
|
||||
<span className="w-[76px] flex-shrink-0 text-[10px] font-medium text-muted-foreground">메인 테이블</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] font-semibold">{currentMainTable || "-"}</span>
|
||||
{stats.fieldCount > 0 && <span className="flex-shrink-0 font-mono text-[9px] text-muted-foreground">{stats.fieldCount} 컬럼</span>}
|
||||
</div>
|
||||
{filterTables.map((ft, idx) => (
|
||||
<div key={idx} className="flex h-[30px] items-center rounded-md px-2 text-[11px] transition-colors hover:bg-muted/30">
|
||||
<span className="w-[76px] flex-shrink-0 text-[10px] font-medium text-muted-foreground">필터 테이블</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] font-semibold text-emerald-500">{ft.tableName}</span>
|
||||
<span className="flex-shrink-0 font-mono text-[9px] text-muted-foreground">FK</span>
|
||||
</div>
|
||||
))}
|
||||
{filterTables.some(ft => ft.joinColumnRefs && ft.joinColumnRefs.length > 0) && (
|
||||
<div className="flex h-[30px] items-center rounded-md px-2 text-[11px] transition-colors hover:bg-muted/30">
|
||||
<span className="w-[76px] flex-shrink-0 text-[10px] font-medium text-muted-foreground">엔티티 조인</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold">
|
||||
{filterTables.flatMap(ft => ft.joinColumnRefs || []).map((j, i) => (
|
||||
<span key={i}>{i > 0 && ", "}<span className="font-mono">{j.column}</span> → <span className="font-mono text-amber-500">{j.refTable}</span></span>
|
||||
))}
|
||||
</span>
|
||||
<span className="flex-shrink-0 font-mono text-[9px] text-muted-foreground">{stats.joinCount}개</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-[30px] items-center rounded-md px-2 text-[11px] transition-colors hover:bg-muted/30">
|
||||
<span className="w-[76px] flex-shrink-0 text-[10px] font-medium text-muted-foreground">컴포넌트</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold">
|
||||
{stats.inputCount > 0 && <>입력 {stats.inputCount}</>}
|
||||
{stats.gridCount > 0 && <>{stats.inputCount > 0 && " · "}그리드 {stats.gridCount}</>}
|
||||
{stats.buttonCount > 0 && <>{(stats.inputCount > 0 || stats.gridCount > 0) && " · "}버튼 {stats.buttonCount}</>}
|
||||
{stats.inputCount === 0 && stats.gridCount === 0 && stats.buttonCount === 0 && `${componentCount}개`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 1: 화면 개요 */}
|
||||
<TabsContent value="overview" className="mt-0 min-h-0 flex-1 overflow-auto p-3">
|
||||
<OverviewTab
|
||||
screenId={currentScreenId}
|
||||
screenName={currentScreenName}
|
||||
mainTable={currentMainTable}
|
||||
mainTableLabel={currentMainTableLabel}
|
||||
filterTables={filterTables}
|
||||
fieldMappings={fieldMappings}
|
||||
componentCount={componentCount}
|
||||
dataFlows={dataFlows}
|
||||
layoutItems={layoutItems}
|
||||
loading={loading}
|
||||
onRefresh={handleRefresh}
|
||||
onOpenTableSetting={handleOpenTableSetting}
|
||||
/>
|
||||
</TabsContent>
|
||||
<div className="my-4 h-px bg-border/40" />
|
||||
|
||||
{/* 탭 2: 테이블 설정 */}
|
||||
<TabsContent value="table-setting" className="mt-0 min-h-0 flex-1 overflow-hidden p-0">
|
||||
{mainTable && (
|
||||
<TableSettingModal
|
||||
isOpen={true}
|
||||
onClose={() => {}} // 탭에서는 닫기 불필요
|
||||
tableName={mainTable}
|
||||
tableLabel={mainTableLabel}
|
||||
screenId={currentScreenId}
|
||||
onSaveSuccess={handleRefresh}
|
||||
isEmbedded={true} // 임베드 모드
|
||||
/>
|
||||
{/* 3. 테이블 섹션 */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 flex items-center gap-1.5 border-b border-border/30 pb-2">
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded bg-blue-500/10">
|
||||
<Database className="h-2.5 w-2.5 text-blue-500" />
|
||||
</div>
|
||||
<span className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">테이블</span>
|
||||
<Badge variant="secondary" className="ml-auto h-4 rounded-full px-1.5 text-[9px] font-bold bg-blue-500/10 text-blue-500 hover:bg-blue-500/10">{stats.tableCount}</Badge>
|
||||
</div>
|
||||
<p className="mb-2 text-[10px] text-muted-foreground/70">컬럼 타입이나 조인을 변경하려면 "설정"을 눌러요</p>
|
||||
<div className="space-y-1">
|
||||
{currentMainTable && (
|
||||
<div className="flex items-center gap-2.5 rounded-lg border border-border/40 bg-muted/20 p-2.5 transition-colors hover:border-border/60 hover:bg-muted/30">
|
||||
<div className="h-6 w-[3px] flex-shrink-0 rounded-full bg-blue-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-mono text-[11px] font-semibold">{currentMainTable}</div>
|
||||
<div className="text-[9px] text-muted-foreground">메인 · {stats.fieldCount} 컬럼 사용중</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-6 px-2.5 text-[10px] font-semibold border-border/50" onClick={() => handleOpenTableSetting(currentMainTable, currentMainTableLabel)}>설정</Button>
|
||||
</div>
|
||||
)}
|
||||
{filterTables.map((ft, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2.5 rounded-lg border border-border/40 bg-muted/20 p-2.5 transition-colors hover:border-border/60 hover:bg-muted/30">
|
||||
<div className="h-6 w-[3px] flex-shrink-0 rounded-full bg-emerald-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-mono text-[11px] font-semibold">{ft.tableName}</div>
|
||||
<div className="text-[9px] text-muted-foreground">필터{ft.filterKeyMapping ? ` · FK: ${ft.filterKeyMapping.filterTableColumn}` : ""}</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-6 px-2.5 text-[10px] font-semibold border-border/50" onClick={() => handleOpenTableSetting(ft.tableName, ft.tableLabel)}>설정</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-4 h-px bg-border/40" />
|
||||
|
||||
{/* 4. 버튼 섹션 (읽기 전용) */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 flex items-center gap-1.5 border-b border-border/30 pb-2">
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded bg-amber-500/10">
|
||||
<MousePointer className="h-2.5 w-2.5 text-amber-500" />
|
||||
</div>
|
||||
<span className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">버튼</span>
|
||||
<Badge variant="secondary" className="ml-auto h-4 rounded-full px-1.5 text-[9px] font-bold bg-amber-500/10 text-amber-500 hover:bg-amber-500/10">{stats.buttonCount}</Badge>
|
||||
</div>
|
||||
<p className="mb-2 text-[10px] text-muted-foreground/70">버튼 편집은 화면 디자이너에서 해요</p>
|
||||
{buttonControls.length > 0 ? (
|
||||
<div className="space-y-0">
|
||||
{buttonControls.map((btn) => (
|
||||
<div key={btn.id} className="flex items-center gap-2 border-b border-border/20 py-1.5 last:border-b-0">
|
||||
<span
|
||||
className="flex h-5 flex-shrink-0 items-center rounded px-2 text-[9px] font-bold"
|
||||
style={{
|
||||
backgroundColor: btn.backgroundColor ? `${btn.backgroundColor}20` : "hsl(var(--muted))",
|
||||
color: btn.textColor || btn.backgroundColor || "hsl(var(--foreground))",
|
||||
}}
|
||||
>{btn.label}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[10px] text-muted-foreground">{btn.actionType?.toUpperCase() || "CUSTOM"}</div>
|
||||
{btn.confirmMessage && <div className="truncate text-[9px] italic text-muted-foreground/60">"{btn.confirmMessage}"</div>}
|
||||
</div>
|
||||
{btn.linkedFlows && btn.linkedFlows.length > 0 && (
|
||||
<Badge variant="secondary" className="h-4 rounded px-1.5 text-[8px] font-bold bg-violet-500/10 text-violet-500 hover:bg-violet-500/10">
|
||||
플로우 {btn.linkedFlows.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center text-[10px] text-muted-foreground/50">버튼이 없어요</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
{/* 탭 3: 제어 관리 */}
|
||||
<TabsContent value="control-management" className="mt-0 min-h-0 flex-1 overflow-auto p-3">
|
||||
<ControlManagementTab
|
||||
screenId={currentScreenId}
|
||||
groupId={groupId}
|
||||
layoutItems={layoutItems}
|
||||
loading={loading}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
</TabsContent>
|
||||
<div className="my-4 h-px bg-border/40" />
|
||||
|
||||
{/* 탭 3: 데이터 흐름 */}
|
||||
<TabsContent value="data-flow" className="mt-0 min-h-0 flex-1 overflow-auto p-3">
|
||||
<DataFlowTab
|
||||
screenId={currentScreenId}
|
||||
groupId={groupId}
|
||||
dataFlows={dataFlows}
|
||||
loading={loading}
|
||||
onReload={loadData}
|
||||
onSaveSuccess={onSaveSuccess}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{/* 5. 데이터 흐름 섹션 */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 flex items-center gap-1.5 border-b border-border/30 pb-2">
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded bg-rose-500/10">
|
||||
<ArrowRight className="h-2.5 w-2.5 text-rose-500" />
|
||||
</div>
|
||||
<span className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">데이터 흐름</span>
|
||||
<Badge variant="secondary" className="ml-auto h-4 rounded-full px-1.5 text-[9px] font-bold bg-rose-500/10 text-rose-500 hover:bg-rose-500/10">{stats.flowCount}</Badge>
|
||||
</div>
|
||||
{dataFlows.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{dataFlows.map((flow) => (
|
||||
<div key={flow.id} className="flex items-center gap-2.5 rounded-lg border border-border/40 bg-muted/20 p-2.5">
|
||||
<div className="h-6 w-[3px] flex-shrink-0 rounded-full bg-rose-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[11px] font-semibold">{flow.source_action || flow.flow_type} → {flow.target_screen_name || `화면 ${flow.target_screen_id}`}</div>
|
||||
<div className="text-[9px] text-muted-foreground">{flow.flow_type}{flow.flow_label ? ` · ${flow.flow_label}` : ""}</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive" onClick={async () => {
|
||||
if (!confirm("정말 삭제하시겠습니까?")) return;
|
||||
const res = await deleteDataFlow(flow.id);
|
||||
if (res.success) { toast.success("삭제되었습니다."); loadData(); onSaveSuccess?.(); }
|
||||
else toast.error("삭제 실패");
|
||||
}}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-border/40 py-5 text-center">
|
||||
<ArrowRight className="mx-auto mb-1 h-4 w-4 text-muted-foreground/30" />
|
||||
<div className="text-[11px] font-medium text-muted-foreground/60">데이터 흐름이 없어요</div>
|
||||
<div className="text-[9px] text-muted-foreground/40">다른 화면으로 데이터를 전달하려면 추가해보세요</div>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="mt-2 h-7 w-full text-[10px] font-semibold border-border/40 text-muted-foreground" onClick={() => toast.info("데이터 흐름 추가는 준비 중이에요")}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
흐름 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="my-4 h-px bg-border/40" />
|
||||
|
||||
{/* 6. 플로우 연동 섹션 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-1.5 border-b border-border/30 pb-2">
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded bg-violet-500/10">
|
||||
<Link2 className="h-2.5 w-2.5 text-violet-500" />
|
||||
</div>
|
||||
<span className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">플로우 연동</span>
|
||||
<Badge variant="secondary" className="ml-auto h-4 rounded-full px-1.5 text-[9px] font-bold bg-violet-500/10 text-violet-500 hover:bg-violet-500/10">{linkedFlowCount}</Badge>
|
||||
</div>
|
||||
{linkedFlowCount > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{buttonControls.filter(b => b.linkedFlows && b.linkedFlows.length > 0).flatMap(btn =>
|
||||
(btn.linkedFlows || []).map(flow => (
|
||||
<div key={`${btn.id}-${flow.id}`} className="flex items-center gap-2.5 rounded-lg border border-violet-500/10 bg-violet-500/[0.03] p-2.5">
|
||||
<div className="h-6 w-[3px] flex-shrink-0 rounded-full bg-violet-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[11px] font-semibold">{flow.name || `플로우 #${flow.id}`}</div>
|
||||
<div className="text-[9px] text-muted-foreground">{btn.label} 버튼 · {flow.timing === "before" ? "실행 전" : "실행 후"}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-3 text-center text-[10px] text-muted-foreground/50">연동된 플로우가 없어요</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA: 화면 디자이너 열기 */}
|
||||
<div className="flex-shrink-0 border-t border-border/40 p-3">
|
||||
<Button
|
||||
className="h-8 w-full gap-1.5 bg-gradient-to-r from-primary to-blue-500 text-[11px] font-bold tracking-tight text-primary-foreground shadow-md shadow-primary/20 transition-all hover:shadow-lg hover:shadow-primary/30"
|
||||
onClick={() => setShowDesignerModal(true)}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
화면 디자이너에서 편집
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 화면 프리뷰 (60%, 항상 표시) */}
|
||||
<div className="flex min-h-0 w-[60%] flex-col overflow-hidden rounded-lg border bg-card">
|
||||
<PreviewTab
|
||||
screenId={currentScreenId}
|
||||
screenName={currentScreenName}
|
||||
companyCode={companyCode}
|
||||
{/* 오른쪽: 화면 프리뷰 */}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-border/40">
|
||||
<PreviewTab
|
||||
screenId={currentScreenId}
|
||||
screenName={currentScreenName}
|
||||
companyCode={companyCode}
|
||||
iframeKey={iframeKey}
|
||||
canvasWidth={canvasSize.width}
|
||||
canvasHeight={canvasSize.height}
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
|||
const fetchScreens = async () => {
|
||||
try {
|
||||
setScreensLoading(true);
|
||||
const response = await apiClient.get("/screen-management/screens");
|
||||
const response = await apiClient.get("/screen-management/screens?size=1000");
|
||||
|
||||
if (response.data.success && Array.isArray(response.data.data)) {
|
||||
const screenList = response.data.data.map((screen: any) => ({
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ const FORMAT_PATTERNS: Record<V2InputFormat, { pattern: RegExp; placeholder: str
|
|||
errorMessage: "올바른 이메일 형식이 아닙니다",
|
||||
},
|
||||
tel: {
|
||||
pattern: /^\d{2,3}-\d{3,4}-\d{4}$/,
|
||||
pattern:
|
||||
/^(01[016789]|02|0[3-7]1|0[3-6][2-5]|050[2-8]|070|080)-\d{3,4}-\d{4}$|^(15|16|18)\d{2}-\d{4}$/,
|
||||
placeholder: "010-1234-5678",
|
||||
errorMessage: "올바른 전화번호 형식이 아닙니다",
|
||||
},
|
||||
|
|
@ -80,8 +81,34 @@ function formatBizNo(value: string): string {
|
|||
// 전화번호 형식 변환
|
||||
function formatTel(value: string): string {
|
||||
const digits = value.replace(/\D/g, "");
|
||||
if (digits.length === 0) return "";
|
||||
|
||||
// 대표번호: 15xx, 16xx, 18xx → 4-4
|
||||
if (/^(15|16|18)/.test(digits)) {
|
||||
if (digits.length <= 4) return digits;
|
||||
return `${digits.slice(0, 4)}-${digits.slice(4, 8)}`;
|
||||
}
|
||||
|
||||
// 서울: 02 → 2-4-4
|
||||
if (digits.startsWith("02")) {
|
||||
if (digits.length <= 2) return digits;
|
||||
if (digits.length <= 6) return `${digits.slice(0, 2)}-${digits.slice(2)}`;
|
||||
if (digits.length <= 10) return `${digits.slice(0, 2)}-${digits.slice(2, 6)}-${digits.slice(6)}`;
|
||||
return `${digits.slice(0, 2)}-${digits.slice(2, 6)}-${digits.slice(6, 10)}`;
|
||||
}
|
||||
|
||||
// 안심번호: 050x → 4-4-4
|
||||
if (/^050[2-8]/.test(digits)) {
|
||||
if (digits.length <= 4) return digits;
|
||||
if (digits.length <= 8) return `${digits.slice(0, 4)}-${digits.slice(4)}`;
|
||||
if (digits.length <= 12) return `${digits.slice(0, 4)}-${digits.slice(4, 8)}-${digits.slice(8)}`;
|
||||
return `${digits.slice(0, 4)}-${digits.slice(4, 8)}-${digits.slice(8, 12)}`;
|
||||
}
|
||||
|
||||
// 나머지 (010, 031, 070, 080 등)
|
||||
if (digits.length <= 3) return digits;
|
||||
if (digits.length <= 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
|
||||
if (digits.length === 10) return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||
if (digits.length <= 11) return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
|
||||
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7, 11)}`;
|
||||
}
|
||||
|
|
@ -1128,7 +1155,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
const hasCustomRadius = !!style?.borderRadius;
|
||||
|
||||
const customTextStyle: React.CSSProperties = {};
|
||||
if (style?.color) customTextStyle.color = style.color;
|
||||
if (style?.color) customTextStyle.color = getAdaptiveLabelColor(style.color);
|
||||
if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
|
||||
if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight;
|
||||
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
|
||||
|
|
@ -1175,8 +1202,8 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
ref={ref}
|
||||
id={id}
|
||||
className={cn(
|
||||
"flex flex-col gap-1",
|
||||
labelPos === "left" ? "sm:flex-row sm:items-center" : "sm:flex-row-reverse sm:items-center",
|
||||
"flex gap-1",
|
||||
labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center",
|
||||
)}
|
||||
style={{
|
||||
width: componentWidth,
|
||||
|
|
@ -1191,7 +1218,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
color: getAdaptiveLabelColor(style?.labelColor),
|
||||
fontWeight: style?.labelFontWeight || "500",
|
||||
}}
|
||||
className="w-full text-sm font-medium whitespace-nowrap sm:w-[120px] sm:shrink-0"
|
||||
className="text-sm font-medium whitespace-nowrap w-[120px] shrink-0"
|
||||
>
|
||||
{actualLabel}
|
||||
{required && <span className="ml-0.5 text-amber-500">*</span>}
|
||||
|
|
|
|||
|
|
@ -1291,8 +1291,8 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>((props, ref) =
|
|||
ref={ref}
|
||||
id={id}
|
||||
className={cn(
|
||||
"flex flex-col gap-1",
|
||||
labelPos === "left" ? "sm:flex-row sm:items-center" : "sm:flex-row-reverse sm:items-center",
|
||||
"flex gap-1",
|
||||
labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center",
|
||||
isDesignMode && "pointer-events-none",
|
||||
)}
|
||||
style={{
|
||||
|
|
@ -1308,7 +1308,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>((props, ref) =
|
|||
color: getAdaptiveLabelColor(style?.labelColor),
|
||||
fontWeight: style?.labelFontWeight || "500",
|
||||
}}
|
||||
className="w-full text-sm font-medium whitespace-nowrap sm:w-[120px] sm:shrink-0"
|
||||
className="text-sm font-medium whitespace-nowrap w-[120px] shrink-0"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-0.5 text-amber-500">*</span>}
|
||||
|
|
|
|||
|
|
@ -269,6 +269,13 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
|
|||
const [modalScreenOpen, setModalScreenOpen] = useState(false);
|
||||
const [modalSearchTerm, setModalSearchTerm] = useState("");
|
||||
|
||||
// 데이터 전달 필드 매핑 관련
|
||||
const [availableTables, setAvailableTables] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [mappingSourceColumnsMap, setMappingSourceColumnsMap] = useState<Record<string, Array<{ name: string; label: string }>>>({});
|
||||
const [mappingTargetColumns, setMappingTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [fieldMappingOpen, setFieldMappingOpen] = useState(false);
|
||||
const [activeMappingGroupIndex, setActiveMappingGroupIndex] = useState(0);
|
||||
|
||||
const showIconSettings = displayMode === "icon" || displayMode === "icon-text";
|
||||
const currentActionIcons = actionIconMap[actionType] || [];
|
||||
const isNoIconAction = noIconActions.has(actionType);
|
||||
|
|
@ -330,6 +337,76 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
|
|||
setIconSize(config.icon?.size || "보통");
|
||||
}, [config.icon?.name, config.icon?.type, config.icon?.size]);
|
||||
|
||||
// 테이블 목록 로드 (데이터 전달 액션용)
|
||||
useEffect(() => {
|
||||
if (actionType !== "transferData") return;
|
||||
if (availableTables.length > 0) return;
|
||||
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
const response = await apiClient.get("/table-management/tables");
|
||||
if (response.data.success && response.data.data) {
|
||||
const tables = response.data.data.map((t: any) => ({
|
||||
name: t.tableName || t.name,
|
||||
label: t.displayName || t.tableLabel || t.label || t.tableName || t.name,
|
||||
}));
|
||||
setAvailableTables(tables);
|
||||
}
|
||||
} catch {
|
||||
setAvailableTables([]);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, [actionType, availableTables.length]);
|
||||
|
||||
// 테이블 컬럼 로드 헬퍼
|
||||
const loadTableColumns = useCallback(async (tableName: string): Promise<Array<{ name: string; label: string }>> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||
if (response.data.success) {
|
||||
let columnData = response.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
||||
if (Array.isArray(columnData)) {
|
||||
return columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return [];
|
||||
}, []);
|
||||
|
||||
// 멀티 테이블 매핑: 소스/타겟 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (actionType !== "transferData") return;
|
||||
|
||||
const multiTableMappings: Array<{ sourceTable: string }> = config.action?.dataTransfer?.multiTableMappings || [];
|
||||
const targetTable = config.action?.dataTransfer?.targetTable;
|
||||
|
||||
const loadAll = async () => {
|
||||
const sourceTableNames = multiTableMappings.map((m) => m.sourceTable).filter(Boolean);
|
||||
const newMap: Record<string, Array<{ name: string; label: string }>> = {};
|
||||
for (const tbl of sourceTableNames) {
|
||||
if (!mappingSourceColumnsMap[tbl]) {
|
||||
newMap[tbl] = await loadTableColumns(tbl);
|
||||
}
|
||||
}
|
||||
if (Object.keys(newMap).length > 0) {
|
||||
setMappingSourceColumnsMap((prev) => ({ ...prev, ...newMap }));
|
||||
}
|
||||
|
||||
if (targetTable) {
|
||||
const cols = await loadTableColumns(targetTable);
|
||||
setMappingTargetColumns(cols);
|
||||
} else {
|
||||
setMappingTargetColumns([]);
|
||||
}
|
||||
};
|
||||
loadAll();
|
||||
}, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, loadTableColumns]);
|
||||
|
||||
// 화면 목록 로드 (모달 액션용)
|
||||
useEffect(() => {
|
||||
if (actionType !== "modal" && actionType !== "navigate") return;
|
||||
|
|
@ -338,7 +415,7 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
|
|||
const loadScreens = async () => {
|
||||
setScreensLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get("/screen-management/screens");
|
||||
const response = await apiClient.get("/screen-management/screens?size=1000");
|
||||
if (response.data.success && response.data.data) {
|
||||
const screenList = response.data.data.map((s: any) => ({
|
||||
id: s.id || s.screenId,
|
||||
|
|
@ -521,6 +598,8 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
|
|||
modalSearchTerm={modalSearchTerm}
|
||||
setModalSearchTerm={setModalSearchTerm}
|
||||
currentTableName={effectiveTableName}
|
||||
allComponents={allComponents}
|
||||
handleUpdateProperty={handleUpdateProperty}
|
||||
/>
|
||||
|
||||
{/* ─── 아이콘 설정 (접기) ─── */}
|
||||
|
|
@ -657,6 +736,26 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 데이터 전달 필드 매핑 (transferData 액션 전용) */}
|
||||
{actionType === "transferData" && (
|
||||
<>
|
||||
<Separator />
|
||||
<TransferDataFieldMappingSection
|
||||
config={config}
|
||||
onChange={onChange}
|
||||
availableTables={availableTables}
|
||||
mappingSourceColumnsMap={mappingSourceColumnsMap}
|
||||
setMappingSourceColumnsMap={setMappingSourceColumnsMap}
|
||||
mappingTargetColumns={mappingTargetColumns}
|
||||
fieldMappingOpen={fieldMappingOpen}
|
||||
setFieldMappingOpen={setFieldMappingOpen}
|
||||
activeMappingGroupIndex={activeMappingGroupIndex}
|
||||
setActiveMappingGroupIndex={setActiveMappingGroupIndex}
|
||||
loadTableColumns={loadTableColumns}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 제어 기능 */}
|
||||
{actionType !== "excel_upload" && actionType !== "multi_table_excel_upload" && (
|
||||
<>
|
||||
|
|
@ -699,6 +798,8 @@ const ActionDetailSection: React.FC<{
|
|||
modalSearchTerm: string;
|
||||
setModalSearchTerm: (term: string) => void;
|
||||
currentTableName?: string;
|
||||
allComponents?: ComponentData[];
|
||||
handleUpdateProperty?: (path: string, value: any) => void;
|
||||
}> = ({
|
||||
actionType,
|
||||
config,
|
||||
|
|
@ -711,6 +812,8 @@ const ActionDetailSection: React.FC<{
|
|||
modalSearchTerm,
|
||||
setModalSearchTerm,
|
||||
currentTableName,
|
||||
allComponents = [],
|
||||
handleUpdateProperty,
|
||||
}) => {
|
||||
const action = config.action || {};
|
||||
|
||||
|
|
@ -800,7 +903,7 @@ const ActionDetailSection: React.FC<{
|
|||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="화면 검색..."
|
||||
value={modalSearchTerm}
|
||||
|
|
@ -812,8 +915,10 @@ const ActionDetailSection: React.FC<{
|
|||
<CommandGroup>
|
||||
{screens
|
||||
.filter((s) =>
|
||||
!modalSearchTerm ||
|
||||
s.name.toLowerCase().includes(modalSearchTerm.toLowerCase()) ||
|
||||
s.description?.toLowerCase().includes(modalSearchTerm.toLowerCase())
|
||||
s.description?.toLowerCase().includes(modalSearchTerm.toLowerCase()) ||
|
||||
String(s.id).includes(modalSearchTerm)
|
||||
)
|
||||
.map((screen) => (
|
||||
<CommandItem
|
||||
|
|
@ -951,6 +1056,190 @@ const ActionDetailSection: React.FC<{
|
|||
</div>
|
||||
);
|
||||
|
||||
case "transferData":
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<SendHorizontal className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-medium">데이터 전달 설정</span>
|
||||
</div>
|
||||
|
||||
{/* 소스 컴포넌트 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">
|
||||
소스 컴포넌트 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={action.dataTransfer?.sourceComponentId || ""}
|
||||
onValueChange={(v) => {
|
||||
const dt = { ...action.dataTransfer, sourceComponentId: v };
|
||||
updateActionConfig("dataTransfer", dt);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__auto__">
|
||||
<span className="text-xs font-medium">자동 탐색 (현재 활성 테이블)</span>
|
||||
</SelectItem>
|
||||
{allComponents
|
||||
.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
||||
type.includes(t)
|
||||
);
|
||||
})
|
||||
.map((comp: any) => {
|
||||
const compType = comp.componentType || comp.type || "unknown";
|
||||
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
||||
return (
|
||||
<SelectItem key={comp.id} value={comp.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">{compLabel}</span>
|
||||
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 타겟 타입 */}
|
||||
<div>
|
||||
<Label className="text-xs">
|
||||
타겟 타입 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={action.dataTransfer?.targetType || "component"}
|
||||
onValueChange={(v) => {
|
||||
const dt = { ...action.dataTransfer, targetType: v };
|
||||
updateActionConfig("dataTransfer", dt);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="component">같은 화면의 컴포넌트</SelectItem>
|
||||
<SelectItem value="splitPanel">분할 패널 반대편 화면</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 타겟 컴포넌트 선택 */}
|
||||
{action.dataTransfer?.targetType === "component" && (
|
||||
<div>
|
||||
<Label className="text-xs">
|
||||
타겟 컴포넌트 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={action.dataTransfer?.targetComponentId || ""}
|
||||
onValueChange={(v) => {
|
||||
const dt = { ...action.dataTransfer, targetComponentId: v };
|
||||
updateActionConfig("dataTransfer", dt);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="데이터를 받을 컴포넌트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allComponents
|
||||
.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
||||
(t) => type.includes(t)
|
||||
);
|
||||
return isReceivable && comp.id !== action.dataTransfer?.sourceComponentId;
|
||||
})
|
||||
.map((comp: any) => {
|
||||
const compType = comp.componentType || comp.type || "unknown";
|
||||
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
||||
return (
|
||||
<SelectItem key={comp.id} value={comp.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">{compLabel}</span>
|
||||
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 전달 모드 */}
|
||||
<div>
|
||||
<Label className="text-xs">데이터 전달 모드</Label>
|
||||
<Select
|
||||
value={action.dataTransfer?.mode || "append"}
|
||||
onValueChange={(v) => {
|
||||
const dt = { ...action.dataTransfer, mode: v };
|
||||
updateActionConfig("dataTransfer", dt);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="append">추가 (Append)</SelectItem>
|
||||
<SelectItem value="replace">교체 (Replace)</SelectItem>
|
||||
<SelectItem value="merge">병합 (Merge)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 전달 후 초기화 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium">전달 후 소스 선택 초기화</p>
|
||||
<p className="text-[10px] text-muted-foreground">데이터 전달 후 소스의 선택을 해제해요</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={action.dataTransfer?.clearAfterTransfer || false}
|
||||
onCheckedChange={(checked) => {
|
||||
const dt = { ...action.dataTransfer, clearAfterTransfer: checked };
|
||||
updateActionConfig("dataTransfer", dt);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 전달 전 확인 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium">전달 전 확인 메시지</p>
|
||||
<p className="text-[10px] text-muted-foreground">전달 전 확인 다이얼로그를 표시해요</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={action.dataTransfer?.confirmBeforeTransfer || false}
|
||||
onCheckedChange={(checked) => {
|
||||
const dt = { ...action.dataTransfer, confirmBeforeTransfer: checked };
|
||||
updateActionConfig("dataTransfer", dt);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{action.dataTransfer?.confirmBeforeTransfer && (
|
||||
<div>
|
||||
<Label className="text-xs">확인 메시지</Label>
|
||||
<Input
|
||||
value={action.dataTransfer?.confirmMessage || ""}
|
||||
onChange={(e) => {
|
||||
const dt = { ...action.dataTransfer, confirmMessage: e.target.value };
|
||||
updateActionConfig("dataTransfer", dt);
|
||||
}}
|
||||
placeholder="선택한 항목을 전달하시겠습니까?"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{commonMessageSection}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "event":
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||
|
|
@ -1373,6 +1662,386 @@ const IconSettingsSection: React.FC<{
|
|||
);
|
||||
};
|
||||
|
||||
// ─── 데이터 전달 필드 매핑 서브 컴포넌트 (고급 설정 내부) ───
|
||||
const TransferDataFieldMappingSection: React.FC<{
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
availableTables: Array<{ name: string; label: string }>;
|
||||
mappingSourceColumnsMap: Record<string, Array<{ name: string; label: string }>>;
|
||||
setMappingSourceColumnsMap: React.Dispatch<React.SetStateAction<Record<string, Array<{ name: string; label: string }>>>>;
|
||||
mappingTargetColumns: Array<{ name: string; label: string }>;
|
||||
fieldMappingOpen: boolean;
|
||||
setFieldMappingOpen: (open: boolean) => void;
|
||||
activeMappingGroupIndex: number;
|
||||
setActiveMappingGroupIndex: (index: number) => void;
|
||||
loadTableColumns: (tableName: string) => Promise<Array<{ name: string; label: string }>>;
|
||||
}> = ({
|
||||
config,
|
||||
onChange,
|
||||
availableTables,
|
||||
mappingSourceColumnsMap,
|
||||
setMappingSourceColumnsMap,
|
||||
mappingTargetColumns,
|
||||
activeMappingGroupIndex,
|
||||
setActiveMappingGroupIndex,
|
||||
loadTableColumns,
|
||||
}) => {
|
||||
const [sourcePopoverOpen, setSourcePopoverOpen] = useState<Record<string, boolean>>({});
|
||||
const [targetPopoverOpen, setTargetPopoverOpen] = useState<Record<string, boolean>>({});
|
||||
|
||||
const dataTransfer = config.action?.dataTransfer || {};
|
||||
const multiTableMappings: Array<{ sourceTable: string; mappingRules: Array<{ sourceField: string; targetField: string }> }> =
|
||||
dataTransfer.multiTableMappings || [];
|
||||
|
||||
const updateDataTransfer = (field: string, value: any) => {
|
||||
const currentAction = config.action || {};
|
||||
const currentDt = currentAction.dataTransfer || {};
|
||||
onChange({
|
||||
...config,
|
||||
action: {
|
||||
...currentAction,
|
||||
dataTransfer: { ...currentDt, [field]: value },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const activeGroup = multiTableMappings[activeMappingGroupIndex];
|
||||
const activeSourceTable = activeGroup?.sourceTable || "";
|
||||
const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || [];
|
||||
const activeRules = activeGroup?.mappingRules || [];
|
||||
|
||||
const updateGroupField = (field: string, value: any) => {
|
||||
const mappings = [...multiTableMappings];
|
||||
mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value };
|
||||
updateDataTransfer("multiTableMappings", mappings);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">필드 매핑</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
레이어별로 소스 테이블이 다를 때 각각 매핑 규칙을 설정해요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 타겟 테이블 (공통) */}
|
||||
<div>
|
||||
<Label className="text-xs">타겟 테이블 (공통)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||||
{dataTransfer.targetTable
|
||||
? availableTables.find((t) => t.name === dataTransfer.targetTable)?.label ||
|
||||
dataTransfer.targetTable
|
||||
: "타겟 테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||
<CommandList className="max-h-48">
|
||||
<CommandEmpty className="py-3 text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.name}
|
||||
value={`${table.label} ${table.name}`}
|
||||
onSelect={() => updateDataTransfer("targetTable", table.name)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-3 w-3", dataTransfer.targetTable === table.name ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.label}</span>
|
||||
{table.label !== table.name && <span className="text-[10px] text-muted-foreground">{table.name}</span>}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 소스 테이블 그룹 탭 + 추가 버튼 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">소스 테이블별 매핑</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
updateDataTransfer("multiTableMappings", [
|
||||
...multiTableMappings,
|
||||
{ sourceTable: "", mappingRules: [] },
|
||||
]);
|
||||
setActiveMappingGroupIndex(multiTableMappings.length);
|
||||
}}
|
||||
disabled={!dataTransfer.targetTable}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
소스 테이블 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!dataTransfer.targetTable ? (
|
||||
<div className="rounded-lg border border-dashed p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground">타겟 테이블을 먼저 선택하세요</p>
|
||||
</div>
|
||||
) : multiTableMappings.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground">소스 테이블을 추가하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* 그룹 탭 */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{multiTableMappings.map((group, gIdx) => (
|
||||
<div key={gIdx} className="flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant={activeMappingGroupIndex === gIdx ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-7 text-xs rounded-r-none"
|
||||
onClick={() => setActiveMappingGroupIndex(gIdx)}
|
||||
>
|
||||
{group.sourceTable
|
||||
? availableTables.find((t) => t.name === group.sourceTable)?.label || group.sourceTable
|
||||
: `그룹 ${gIdx + 1}`}
|
||||
{group.mappingRules?.length > 0 && (
|
||||
<span className="ml-1.5 rounded-full bg-primary-foreground/20 px-1.5 text-[10px]">
|
||||
{group.mappingRules.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={activeMappingGroupIndex === gIdx ? "default" : "outline"}
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-l-none border-l-0"
|
||||
onClick={() => {
|
||||
const mappings = [...multiTableMappings];
|
||||
mappings.splice(gIdx, 1);
|
||||
updateDataTransfer("multiTableMappings", mappings);
|
||||
if (activeMappingGroupIndex >= mappings.length) {
|
||||
setActiveMappingGroupIndex(Math.max(0, mappings.length - 1));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 활성 그룹 편집 */}
|
||||
{activeGroup && (
|
||||
<div className="space-y-3 rounded-lg border p-3">
|
||||
{/* 소스 테이블 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">소스 테이블</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||||
{activeSourceTable
|
||||
? availableTables.find((t) => t.name === activeSourceTable)?.label || activeSourceTable
|
||||
: "소스 테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||
<CommandList className="max-h-48">
|
||||
<CommandEmpty className="py-3 text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.name}
|
||||
value={`${table.label} ${table.name}`}
|
||||
onSelect={async () => {
|
||||
updateGroupField("sourceTable", table.name);
|
||||
if (!mappingSourceColumnsMap[table.name]) {
|
||||
const cols = await loadTableColumns(table.name);
|
||||
setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols }));
|
||||
}
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-3 w-3", activeSourceTable === table.name ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.label}</span>
|
||||
{table.label !== table.name && <span className="text-[10px] text-muted-foreground">{table.name}</span>}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 매핑 규칙 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">매핑 규칙</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
updateGroupField("mappingRules", [
|
||||
...activeRules,
|
||||
{ sourceField: "", targetField: "" },
|
||||
]);
|
||||
}}
|
||||
disabled={!activeSourceTable}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!activeSourceTable ? (
|
||||
<p className="text-xs text-muted-foreground">소스 테이블을 먼저 선택하세요</p>
|
||||
) : activeRules.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">매핑 없음 (동일 필드명 자동 매핑)</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{activeRules.map((rule: any, rIdx: number) => {
|
||||
const keyS = `${activeMappingGroupIndex}-${rIdx}-s`;
|
||||
const keyT = `${activeMappingGroupIndex}-${rIdx}-t`;
|
||||
return (
|
||||
<div
|
||||
key={rIdx}
|
||||
className="grid items-center gap-1.5"
|
||||
style={{ gridTemplateColumns: "1fr 16px 1fr 32px" }}
|
||||
>
|
||||
{/* 소스 필드 */}
|
||||
<Popover
|
||||
open={sourcePopoverOpen[keyS] || false}
|
||||
onOpenChange={(open) => setSourcePopoverOpen((prev) => ({ ...prev, [keyS]: open }))}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs overflow-hidden">
|
||||
<span className="truncate">
|
||||
{rule.sourceField
|
||||
? activeSourceColumns.find((c) => c.name === rule.sourceField)?.label || rule.sourceField
|
||||
: "소스 컬럼"}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[220px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||
<CommandList className="max-h-48">
|
||||
<CommandEmpty className="py-3 text-xs">찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{activeSourceColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={`${col.label} ${col.name}`}
|
||||
onSelect={() => {
|
||||
const newRules = [...activeRules];
|
||||
newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name };
|
||||
updateGroupField("mappingRules", newRules);
|
||||
setSourcePopoverOpen((prev) => ({ ...prev, [keyS]: false }));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-3 w-3", rule.sourceField === col.name ? "opacity-100" : "opacity-0")} />
|
||||
<span className="font-medium">{col.label}</span>
|
||||
{col.label !== col.name && <span className="ml-1 text-muted-foreground">({col.name})</span>}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<ArrowRight className="mx-auto h-4 w-4 text-muted-foreground" />
|
||||
|
||||
{/* 타겟 필드 */}
|
||||
<Popover
|
||||
open={targetPopoverOpen[keyT] || false}
|
||||
onOpenChange={(open) => setTargetPopoverOpen((prev) => ({ ...prev, [keyT]: open }))}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs overflow-hidden">
|
||||
<span className="truncate">
|
||||
{rule.targetField
|
||||
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label || rule.targetField
|
||||
: "타겟 컬럼"}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[220px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||
<CommandList className="max-h-48">
|
||||
<CommandEmpty className="py-3 text-xs">찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{mappingTargetColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={`${col.label} ${col.name}`}
|
||||
onSelect={() => {
|
||||
const newRules = [...activeRules];
|
||||
newRules[rIdx] = { ...newRules[rIdx], targetField: col.name };
|
||||
updateGroupField("mappingRules", newRules);
|
||||
setTargetPopoverOpen((prev) => ({ ...prev, [keyT]: false }));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-3 w-3", rule.targetField === col.name ? "opacity-100" : "opacity-0")} />
|
||||
<span className="font-medium">{col.label}</span>
|
||||
{col.label !== col.name && <span className="ml-1 text-muted-foreground">({col.name})</span>}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* 삭제 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
const newRules = [...activeRules];
|
||||
newRules.splice(rIdx, 1);
|
||||
updateGroupField("mappingRules", newRules);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
V2ButtonConfigPanel.displayName = "V2ButtonConfigPanel";
|
||||
|
||||
export default V2ButtonConfigPanel;
|
||||
|
|
|
|||
|
|
@ -82,9 +82,10 @@ import {
|
|||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type {
|
||||
SplitPanelLayoutConfig,
|
||||
AdditionalTabConfig,
|
||||
import {
|
||||
MAX_LOAD_ALL_SIZE,
|
||||
type SplitPanelLayoutConfig,
|
||||
type AdditionalTabConfig,
|
||||
} from "@/lib/registry/components/v2-split-panel-layout/types";
|
||||
import type { TableInfo, ColumnInfo } from "@/types/screen";
|
||||
|
||||
|
|
@ -1158,6 +1159,41 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
|
|||
updateLeftPanel({ showItemAddButton: checked })
|
||||
}
|
||||
/>
|
||||
<SwitchRow
|
||||
label="페이징 처리"
|
||||
description="서버 페이지 단위 조회 (필터/정렬/계층 비활성화)"
|
||||
checked={config.leftPanel?.pagination?.enabled ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateLeftPanel({
|
||||
pagination: {
|
||||
...config.leftPanel?.pagination,
|
||||
enabled: checked,
|
||||
pageSize: config.leftPanel?.pagination?.pageSize ?? 20,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
{config.leftPanel?.pagination?.enabled && (
|
||||
<div className="ml-4 space-y-1">
|
||||
<Label className="text-[10px]">페이지 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_LOAD_ALL_SIZE}
|
||||
value={config.leftPanel?.pagination?.pageSize ?? 20}
|
||||
onChange={(e) =>
|
||||
updateLeftPanel({
|
||||
pagination: {
|
||||
...config.leftPanel?.pagination,
|
||||
enabled: true,
|
||||
pageSize: Math.min(MAX_LOAD_ALL_SIZE, Math.max(1, Number(e.target.value) || 20)),
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-7 w-24 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 좌측 패널 컬럼 설정 (접이식) */}
|
||||
|
|
@ -1564,6 +1600,41 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
|
|||
updateRightPanel({ showDelete: checked })
|
||||
}
|
||||
/>
|
||||
<SwitchRow
|
||||
label="페이징 처리"
|
||||
description="서버 페이지 단위 조회 (탭 포함 적용)"
|
||||
checked={config.rightPanel?.pagination?.enabled ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateRightPanel({
|
||||
pagination: {
|
||||
...config.rightPanel?.pagination,
|
||||
enabled: checked,
|
||||
pageSize: config.rightPanel?.pagination?.pageSize ?? 20,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
{config.rightPanel?.pagination?.enabled && (
|
||||
<div className="ml-4 space-y-1">
|
||||
<Label className="text-[10px]">페이지 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_LOAD_ALL_SIZE}
|
||||
value={config.rightPanel?.pagination?.pageSize ?? 20}
|
||||
onChange={(e) =>
|
||||
updateRightPanel({
|
||||
pagination: {
|
||||
...config.rightPanel?.pagination,
|
||||
enabled: true,
|
||||
pageSize: Math.min(MAX_LOAD_ALL_SIZE, Math.max(1, Number(e.target.value) || 20)),
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-7 w-24 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측 패널 컬럼 설정 (접이식) */}
|
||||
|
|
|
|||
|
|
@ -957,7 +957,38 @@ export const V2TableListConfigPanel: React.FC<V2TableListConfigPanelProps> = ({
|
|||
/>
|
||||
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
|
||||
<span className="truncate text-xs">{column.columnLabel}</span>
|
||||
<span className="ml-auto text-[10px] text-primary/80">
|
||||
{isAlreadyAdded && (
|
||||
<button
|
||||
type="button"
|
||||
title={
|
||||
config.columns?.find((c) => c.columnName === matchingJoinColumn.joinAlias)?.editable === false
|
||||
? "편집 잠금 (클릭하여 해제)"
|
||||
: "편집 가능 (클릭하여 잠금)"
|
||||
}
|
||||
className={cn(
|
||||
"ml-auto flex-shrink-0 rounded p-0.5 transition-colors",
|
||||
config.columns?.find((c) => c.columnName === matchingJoinColumn.joinAlias)?.editable === false
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const currentCol = config.columns?.find((c) => c.columnName === matchingJoinColumn.joinAlias);
|
||||
if (currentCol) {
|
||||
updateColumn(matchingJoinColumn.joinAlias, {
|
||||
editable: currentCol.editable === false ? undefined : false,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{config.columns?.find((c) => c.columnName === matchingJoinColumn.joinAlias)?.editable === false ? (
|
||||
<Lock className="h-3 w-3" />
|
||||
) : (
|
||||
<Unlock className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<span className={cn("text-[10px] text-primary/80", !isAlreadyAdded && "ml-auto")}>
|
||||
{column.inputType || column.dataType}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -102,12 +102,16 @@ function mergeColumnMeta(tableName: string | undefined, columnName: string | und
|
|||
if (dbInputType === "entity") {
|
||||
const refTable = meta.reference_table || meta.referenceTable;
|
||||
const refColumn = meta.reference_column || meta.referenceColumn;
|
||||
const displayCol = meta.display_column || meta.displayColumn;
|
||||
const rawDisplayCol = meta.display_column || meta.displayColumn;
|
||||
const displayCol = rawDisplayCol && rawDisplayCol !== "none" && rawDisplayCol !== "" ? rawDisplayCol : undefined;
|
||||
if (refTable) {
|
||||
merged.source = "entity";
|
||||
merged.entityTable = refTable;
|
||||
merged.entityValueColumn = refColumn || "id";
|
||||
merged.entityLabelColumn = displayCol || "name";
|
||||
// 화면 설정에 이미 entityLabelColumn이 있으면 유지, 없으면 DB 값 또는 기본값 사용
|
||||
if (!merged.entityLabelColumn) {
|
||||
merged.entityLabelColumn = displayCol || "name";
|
||||
}
|
||||
merged.fieldType = "entity";
|
||||
merged.inputType = "entity";
|
||||
}
|
||||
|
|
@ -531,7 +535,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
}
|
||||
: (component as any).style;
|
||||
const catSize = catNeedsExternalHorizLabel
|
||||
? { ...(component as any).size, width: undefined, height: undefined }
|
||||
? { ...(component as any).size, width: undefined }
|
||||
: (component as any).size;
|
||||
|
||||
const rendererProps = {
|
||||
|
|
@ -797,35 +801,33 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
componentType === "modal-repeater-table" ||
|
||||
componentType === "v2-input";
|
||||
|
||||
// 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true/"true"일 때만 라벨 표시)
|
||||
// 🆕 v2-input 등의 라벨 표시 로직 (InteractiveScreenViewerDynamic과 동일한 부정형 체크)
|
||||
const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
|
||||
const effectiveLabel =
|
||||
labelDisplay === true || labelDisplay === "true"
|
||||
labelDisplay !== false && labelDisplay !== "false"
|
||||
? component.style?.labelText || (component as any).label || component.componentConfig?.label
|
||||
: undefined;
|
||||
|
||||
// 🔧 수평 라벨(left/right) 감지 → 외부 flex 컨테이너에서 라벨 처리
|
||||
// 🔧 수평 라벨(left/right) 감지 → 런타임에서만 외부 flex 컨테이너로 라벨 처리
|
||||
// 디자인 모드에서는 V2 컴포넌트가 자체적으로 라벨을 렌더링 (height 체인 문제 방지)
|
||||
const labelPosition = component.style?.labelPosition;
|
||||
const isV2Component = componentType?.startsWith("v2-");
|
||||
const needsExternalHorizLabel = !!(
|
||||
!props.isDesignMode &&
|
||||
isV2Component &&
|
||||
effectiveLabel &&
|
||||
(labelPosition === "left" || labelPosition === "right")
|
||||
);
|
||||
|
||||
// 🔧 순서 중요! component.style 먼저, CSS 크기 속성은 size 기반으로 덮어씀
|
||||
const mergedStyle = {
|
||||
...component.style, // 원본 style (labelDisplay, labelText 등) - 먼저!
|
||||
// CSS 크기 속성은 size에서 계산한 값으로 명시적 덮어쓰기 (우선순위 최고)
|
||||
...component.style,
|
||||
width: finalStyle.width,
|
||||
height: finalStyle.height,
|
||||
// 수평 라벨 → V2 컴포넌트에는 라벨 비활성화 (외부에서 처리)
|
||||
...(needsExternalHorizLabel
|
||||
? {
|
||||
labelDisplay: false,
|
||||
labelPosition: "top" as const,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderWidth: undefined,
|
||||
borderColor: undefined,
|
||||
borderStyle: undefined,
|
||||
|
|
|
|||
|
|
@ -1751,7 +1751,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
<TableHeader>
|
||||
<TableRow>
|
||||
{displayColumns.map((col, idx) => (
|
||||
<TableHead key={idx} style={{ width: col.width && col.width <= 100 ? `${col.width}%` : "auto" }}>
|
||||
<TableHead key={idx} style={{ minWidth: col.width ? `${col.width}px` : "80px" }}>
|
||||
{col.label || col.name}
|
||||
</TableHead>
|
||||
))}
|
||||
|
|
@ -1952,7 +1952,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
</TableHead>
|
||||
)}
|
||||
{displayColumns.map((col, idx) => (
|
||||
<TableHead key={idx} style={{ width: col.width && col.width <= 100 ? `${col.width}%` : "auto" }}>
|
||||
<TableHead key={idx} style={{ minWidth: col.width ? `${col.width}px` : "80px" }}>
|
||||
{col.label || col.name}
|
||||
</TableHead>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { ComponentRendererProps } from "../../types";
|
||||
import { SplitPanelLayoutConfig } from "./types";
|
||||
import { SplitPanelLayoutConfig, MAX_LOAD_ALL_SIZE } from "./types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -16,6 +16,9 @@ import {
|
|||
ChevronUp,
|
||||
Save,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Settings,
|
||||
|
|
@ -48,6 +51,66 @@ import { cn } from "@/lib/utils";
|
|||
import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer";
|
||||
import { BomExcelUploadModal } from "../v2-bom-tree/BomExcelUploadModal";
|
||||
|
||||
/** 클라이언트 사이드 데이터 필터 (페이징 OFF 전용) */
|
||||
function applyClientSideFilter(data: any[], dataFilter: any): any[] {
|
||||
if (!dataFilter?.enabled) return data;
|
||||
|
||||
let result = data;
|
||||
|
||||
if (dataFilter.filters?.length > 0) {
|
||||
const matchFn = dataFilter.matchType === "any" ? "some" : "every";
|
||||
result = result.filter((item: any) =>
|
||||
dataFilter.filters[matchFn]((cond: any) => {
|
||||
const val = item[cond.columnName];
|
||||
switch (cond.operator) {
|
||||
case "equals":
|
||||
return val === cond.value;
|
||||
case "notEquals":
|
||||
case "not_equals":
|
||||
return val !== cond.value;
|
||||
case "in": {
|
||||
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
|
||||
return arr.includes(val);
|
||||
}
|
||||
case "not_in": {
|
||||
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
|
||||
return !arr.includes(val);
|
||||
}
|
||||
case "contains":
|
||||
return String(val || "").includes(String(cond.value));
|
||||
case "is_null":
|
||||
return val === null || val === undefined || val === "";
|
||||
case "is_not_null":
|
||||
return val !== null && val !== undefined && val !== "";
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// legacy conditions 형식 (하위 호환성)
|
||||
if (dataFilter.conditions?.length > 0) {
|
||||
result = result.filter((item: any) =>
|
||||
dataFilter.conditions.every((cond: any) => {
|
||||
const val = item[cond.column];
|
||||
switch (cond.operator) {
|
||||
case "equals":
|
||||
return val === cond.value;
|
||||
case "notEquals":
|
||||
return val !== cond.value;
|
||||
case "contains":
|
||||
return String(val || "").includes(String(cond.value));
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
||||
// 추가 props
|
||||
onUpdateComponent?: (component: any) => void;
|
||||
|
|
@ -351,6 +414,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const [columnInputTypes, setColumnInputTypes] = useState<Record<string, string>>({});
|
||||
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
||||
|
||||
// 🆕 페이징 상태
|
||||
const [leftCurrentPage, setLeftCurrentPage] = useState(1);
|
||||
const [leftTotalPages, setLeftTotalPages] = useState(1);
|
||||
const [leftTotal, setLeftTotal] = useState(0);
|
||||
const [leftPageSize, setLeftPageSize] = useState(componentConfig.leftPanel?.pagination?.pageSize ?? 20);
|
||||
const [rightCurrentPage, setRightCurrentPage] = useState(1);
|
||||
const [rightTotalPages, setRightTotalPages] = useState(1);
|
||||
const [rightTotal, setRightTotal] = useState(0);
|
||||
const [rightPageSize, setRightPageSize] = useState(componentConfig.rightPanel?.pagination?.pageSize ?? 20);
|
||||
const [tabsPagination, setTabsPagination] = useState<Record<number, { currentPage: number; totalPages: number; total: number; pageSize: number }>>({});
|
||||
const [leftPageInput, setLeftPageInput] = useState("1");
|
||||
const [rightPageInput, setRightPageInput] = useState("1");
|
||||
|
||||
const leftPaginationEnabled = componentConfig.leftPanel?.pagination?.enabled ?? false;
|
||||
const rightPaginationEnabled = componentConfig.rightPanel?.pagination?.enabled ?? false;
|
||||
|
||||
// 추가 탭 관련 상태
|
||||
const [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭, 1+ = 추가 탭
|
||||
const [tabsData, setTabsData] = useState<Record<number, any[]>>({}); // 탭별 데이터
|
||||
|
|
@ -919,13 +998,24 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
|
||||
let columns = displayColumns;
|
||||
|
||||
// columnVisibility가 있으면 가시성 적용
|
||||
// columnVisibility가 있으면 가시성 + 너비 적용
|
||||
if (leftColumnVisibility.length > 0) {
|
||||
const visibilityMap = new Map(leftColumnVisibility.map((cv) => [cv.columnName, cv.visible]));
|
||||
columns = columns.filter((col: any) => {
|
||||
const colName = typeof col === "string" ? col : col.name || col.columnName;
|
||||
return visibilityMap.get(colName) !== false;
|
||||
});
|
||||
const visibilityMap = new Map(
|
||||
leftColumnVisibility.map((cv) => [cv.columnName, cv])
|
||||
);
|
||||
columns = columns
|
||||
.filter((col: any) => {
|
||||
const colName = typeof col === "string" ? col : col.name || col.columnName;
|
||||
return visibilityMap.get(colName)?.visible !== false;
|
||||
})
|
||||
.map((col: any) => {
|
||||
const colName = typeof col === "string" ? col : col.name || col.columnName;
|
||||
const cv = visibilityMap.get(colName);
|
||||
if (cv?.width && typeof col === "object") {
|
||||
return { ...col, width: cv.width };
|
||||
}
|
||||
return col;
|
||||
});
|
||||
}
|
||||
|
||||
// 🔧 컬럼 순서 적용
|
||||
|
|
@ -1241,87 +1331,62 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
return joinColumns.length > 0 ? joinColumns : undefined;
|
||||
}, []);
|
||||
|
||||
// 좌측 데이터 로드
|
||||
const loadLeftData = useCallback(async () => {
|
||||
// 좌측 데이터 로드 (페이징 ON: page 파라미터 사용, OFF: 전체 로드)
|
||||
const loadLeftData = useCallback(async (page?: number, pageSizeOverride?: number) => {
|
||||
const leftTableName = componentConfig.leftPanel?.tableName;
|
||||
if (!leftTableName || isDesignMode) return;
|
||||
|
||||
setIsLoadingLeft(true);
|
||||
try {
|
||||
// 🎯 필터 조건을 API에 전달 (entityJoinApi 사용)
|
||||
const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined;
|
||||
|
||||
// 🆕 좌측 패널 config의 Entity 조인 컬럼 추출 (헬퍼 함수 사용)
|
||||
const leftJoinColumns = extractAdditionalJoinColumns(
|
||||
componentConfig.leftPanel?.columns,
|
||||
leftTableName,
|
||||
);
|
||||
|
||||
console.log("🔗 [분할패널] 좌측 additionalJoinColumns:", leftJoinColumns);
|
||||
if (leftPaginationEnabled) {
|
||||
const currentPageToLoad = page ?? leftCurrentPage;
|
||||
const effectivePageSize = pageSizeOverride ?? leftPageSize;
|
||||
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
|
||||
page: currentPageToLoad,
|
||||
size: effectivePageSize,
|
||||
search: filters,
|
||||
enableEntityJoin: true,
|
||||
dataFilter: componentConfig.leftPanel?.dataFilter,
|
||||
additionalJoinColumns: leftJoinColumns,
|
||||
companyCodeOverride: companyCode,
|
||||
});
|
||||
|
||||
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
|
||||
page: 1,
|
||||
size: 100,
|
||||
search: filters,
|
||||
enableEntityJoin: true,
|
||||
dataFilter: componentConfig.leftPanel?.dataFilter,
|
||||
additionalJoinColumns: leftJoinColumns,
|
||||
companyCodeOverride: companyCode,
|
||||
});
|
||||
setLeftData(result.data || []);
|
||||
setLeftCurrentPage(result.page || currentPageToLoad);
|
||||
setLeftTotalPages(result.totalPages || 1);
|
||||
setLeftTotal(result.total || 0);
|
||||
setLeftPageInput(String(result.page || currentPageToLoad));
|
||||
} else {
|
||||
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
|
||||
page: 1,
|
||||
size: MAX_LOAD_ALL_SIZE,
|
||||
search: filters,
|
||||
enableEntityJoin: true,
|
||||
dataFilter: componentConfig.leftPanel?.dataFilter,
|
||||
additionalJoinColumns: leftJoinColumns,
|
||||
companyCodeOverride: companyCode,
|
||||
});
|
||||
|
||||
// 🔍 디버깅: API 응답 데이터의 키 확인
|
||||
if (result.data && result.data.length > 0) {
|
||||
console.log("🔗 [분할패널] API 응답 첫 번째 데이터 키:", Object.keys(result.data[0]));
|
||||
console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]);
|
||||
}
|
||||
let filteredLeftData = applyClientSideFilter(result.data || [], componentConfig.leftPanel?.dataFilter);
|
||||
|
||||
// 좌측 패널 dataFilter 클라이언트 사이드 적용
|
||||
let filteredLeftData = result.data || [];
|
||||
const leftDataFilter = componentConfig.leftPanel?.dataFilter;
|
||||
if (leftDataFilter?.enabled && leftDataFilter.filters?.length > 0) {
|
||||
const matchFn = leftDataFilter.matchType === "any" ? "some" : "every";
|
||||
filteredLeftData = filteredLeftData.filter((item: any) => {
|
||||
return leftDataFilter.filters[matchFn]((cond: any) => {
|
||||
const val = item[cond.columnName];
|
||||
switch (cond.operator) {
|
||||
case "equals":
|
||||
return val === cond.value;
|
||||
case "not_equals":
|
||||
return val !== cond.value;
|
||||
case "in": {
|
||||
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
|
||||
return arr.includes(val);
|
||||
}
|
||||
case "not_in": {
|
||||
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
|
||||
return !arr.includes(val);
|
||||
}
|
||||
case "contains":
|
||||
return String(val || "").includes(String(cond.value));
|
||||
case "is_null":
|
||||
return val === null || val === undefined || val === "";
|
||||
case "is_not_null":
|
||||
return val !== null && val !== undefined && val !== "";
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
||||
if (leftColumn && filteredLeftData.length > 0) {
|
||||
filteredLeftData.sort((a, b) => {
|
||||
const aValue = String(a[leftColumn] || "");
|
||||
const bValue = String(b[leftColumn] || "");
|
||||
return aValue.localeCompare(bValue, "ko-KR");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 가나다순 정렬 (좌측 패널의 표시 컬럼 기준)
|
||||
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
||||
if (leftColumn && filteredLeftData.length > 0) {
|
||||
filteredLeftData.sort((a, b) => {
|
||||
const aValue = String(a[leftColumn] || "");
|
||||
const bValue = String(b[leftColumn] || "");
|
||||
return aValue.localeCompare(bValue, "ko-KR");
|
||||
});
|
||||
const hierarchicalData = buildHierarchy(filteredLeftData);
|
||||
setLeftData(hierarchicalData);
|
||||
}
|
||||
|
||||
// 계층 구조 빌드
|
||||
const hierarchicalData = buildHierarchy(filteredLeftData);
|
||||
setLeftData(hierarchicalData);
|
||||
} catch (error) {
|
||||
console.error("좌측 데이터 로드 실패:", error);
|
||||
toast({
|
||||
|
|
@ -1337,15 +1402,25 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
componentConfig.leftPanel?.columns,
|
||||
componentConfig.leftPanel?.dataFilter,
|
||||
componentConfig.rightPanel?.relation?.leftColumn,
|
||||
leftPaginationEnabled,
|
||||
leftCurrentPage,
|
||||
leftPageSize,
|
||||
isDesignMode,
|
||||
toast,
|
||||
buildHierarchy,
|
||||
searchValues,
|
||||
]);
|
||||
|
||||
// 우측 데이터 로드 (leftItem이 null이면 전체 데이터 로드)
|
||||
const updateRightPaginationState = useCallback((result: any, fallbackPage: number) => {
|
||||
setRightCurrentPage(result.page || fallbackPage);
|
||||
setRightTotalPages(result.totalPages || 1);
|
||||
setRightTotal(result.total || 0);
|
||||
setRightPageInput(String(result.page || fallbackPage));
|
||||
}, []);
|
||||
|
||||
// 우측 데이터 로드 (leftItem이 null이면 전체 데이터 로드, page: 서버 페이징용)
|
||||
const loadRightData = useCallback(
|
||||
async (leftItem: any) => {
|
||||
async (leftItem: any, page?: number, pageSizeOverride?: number) => {
|
||||
const relationshipType = componentConfig.rightPanel?.relation?.type || "detail";
|
||||
const rightTableName = componentConfig.rightPanel?.tableName;
|
||||
|
||||
|
|
@ -1359,70 +1434,33 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
componentConfig.rightPanel?.columns,
|
||||
rightTableName,
|
||||
);
|
||||
const effectivePageSize = pageSizeOverride ?? rightPageSize;
|
||||
|
||||
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
||||
enableEntityJoin: true,
|
||||
size: 1000,
|
||||
companyCodeOverride: companyCode,
|
||||
additionalJoinColumns: rightJoinColumns,
|
||||
dataFilter: componentConfig.rightPanel?.dataFilter,
|
||||
});
|
||||
|
||||
// dataFilter 적용
|
||||
let filteredData = result.data || [];
|
||||
const dataFilter = componentConfig.rightPanel?.dataFilter;
|
||||
if (dataFilter?.enabled && dataFilter.filters?.length > 0) {
|
||||
filteredData = filteredData.filter((item: any) => {
|
||||
return dataFilter.filters.every((cond: any) => {
|
||||
const value = item[cond.columnName];
|
||||
switch (cond.operator) {
|
||||
case "equals":
|
||||
return value === cond.value;
|
||||
case "notEquals":
|
||||
case "not_equals":
|
||||
return value !== cond.value;
|
||||
case "in": {
|
||||
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
|
||||
return arr.includes(value);
|
||||
}
|
||||
case "not_in": {
|
||||
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
|
||||
return !arr.includes(value);
|
||||
}
|
||||
case "contains":
|
||||
return String(value || "").includes(String(cond.value));
|
||||
case "is_null":
|
||||
return value === null || value === undefined || value === "";
|
||||
case "is_not_null":
|
||||
return value !== null && value !== undefined && value !== "";
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
if (rightPaginationEnabled) {
|
||||
const currentPageToLoad = page ?? rightCurrentPage;
|
||||
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
||||
page: currentPageToLoad,
|
||||
size: effectivePageSize,
|
||||
enableEntityJoin: true,
|
||||
companyCodeOverride: companyCode,
|
||||
additionalJoinColumns: rightJoinColumns,
|
||||
dataFilter: componentConfig.rightPanel?.dataFilter,
|
||||
});
|
||||
}
|
||||
|
||||
// conditions 형식 dataFilter도 지원 (하위 호환성)
|
||||
const dataFilterConditions = componentConfig.rightPanel?.dataFilter;
|
||||
if (dataFilterConditions?.enabled && dataFilterConditions.conditions?.length > 0) {
|
||||
filteredData = filteredData.filter((item: any) => {
|
||||
return dataFilterConditions.conditions.every((cond: any) => {
|
||||
const value = item[cond.column];
|
||||
switch (cond.operator) {
|
||||
case "equals":
|
||||
return value === cond.value;
|
||||
case "notEquals":
|
||||
return value !== cond.value;
|
||||
case "contains":
|
||||
return String(value || "").includes(String(cond.value));
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
setRightData(result.data || []);
|
||||
updateRightPaginationState(result, currentPageToLoad);
|
||||
} else {
|
||||
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
||||
enableEntityJoin: true,
|
||||
size: MAX_LOAD_ALL_SIZE,
|
||||
companyCodeOverride: companyCode,
|
||||
additionalJoinColumns: rightJoinColumns,
|
||||
dataFilter: componentConfig.rightPanel?.dataFilter,
|
||||
});
|
||||
}
|
||||
|
||||
setRightData(filteredData);
|
||||
const filteredData = applyClientSideFilter(result.data || [], componentConfig.rightPanel?.dataFilter);
|
||||
setRightData(filteredData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("우측 전체 데이터 로드 실패:", error);
|
||||
} finally {
|
||||
|
|
@ -1499,9 +1537,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
||||
search: searchConditions,
|
||||
enableEntityJoin: true,
|
||||
size: 1000,
|
||||
size: MAX_LOAD_ALL_SIZE,
|
||||
companyCodeOverride: companyCode,
|
||||
additionalJoinColumns: rightJoinColumnsForGroup, // 🆕 Entity 조인 컬럼 전달
|
||||
additionalJoinColumns: rightJoinColumnsForGroup,
|
||||
});
|
||||
if (result.data) {
|
||||
allResults.push(...result.data);
|
||||
|
|
@ -1540,16 +1578,19 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
console.log("🔗 [분할패널] 우측 패널 additionalJoinColumns:", rightJoinColumns);
|
||||
}
|
||||
|
||||
// 엔티티 조인 API로 데이터 조회
|
||||
const effectivePageSize = pageSizeOverride ?? rightPageSize;
|
||||
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
||||
search: searchConditions,
|
||||
enableEntityJoin: true,
|
||||
size: 1000,
|
||||
size: rightPaginationEnabled ? effectivePageSize : MAX_LOAD_ALL_SIZE,
|
||||
page: rightPaginationEnabled ? (page ?? rightCurrentPage) : undefined,
|
||||
companyCodeOverride: companyCode,
|
||||
additionalJoinColumns: rightJoinColumns,
|
||||
});
|
||||
|
||||
console.log("🔗 [분할패널] 복합키 조회 결과:", result);
|
||||
if (rightPaginationEnabled) {
|
||||
updateRightPaginationState(result, page ?? rightCurrentPage);
|
||||
}
|
||||
|
||||
setRightData(result.data || []);
|
||||
} else {
|
||||
|
|
@ -1576,14 +1617,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
console.log("🔗 [분할패널] 단일키 모드 additionalJoinColumns:", rightJoinColumnsLegacy);
|
||||
}
|
||||
|
||||
const effectivePageSizeLegacy = pageSizeOverride ?? rightPageSize;
|
||||
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
||||
search: searchConditions,
|
||||
enableEntityJoin: true,
|
||||
size: 1000,
|
||||
size: rightPaginationEnabled ? effectivePageSizeLegacy : MAX_LOAD_ALL_SIZE,
|
||||
page: rightPaginationEnabled ? (page ?? rightCurrentPage) : undefined,
|
||||
companyCodeOverride: companyCode,
|
||||
additionalJoinColumns: rightJoinColumnsLegacy,
|
||||
});
|
||||
|
||||
if (rightPaginationEnabled) {
|
||||
updateRightPaginationState(result, page ?? rightCurrentPage);
|
||||
}
|
||||
|
||||
setRightData(result.data || []);
|
||||
}
|
||||
}
|
||||
|
|
@ -1604,14 +1651,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
componentConfig.rightPanel?.tableName,
|
||||
componentConfig.rightPanel?.relation,
|
||||
componentConfig.leftPanel?.tableName,
|
||||
rightPaginationEnabled,
|
||||
rightCurrentPage,
|
||||
rightPageSize,
|
||||
isDesignMode,
|
||||
toast,
|
||||
updateRightPaginationState,
|
||||
],
|
||||
);
|
||||
|
||||
// 추가 탭 데이터 로딩 함수 (leftItem이 null이면 전체 데이터 로드)
|
||||
// 추가 탭 데이터 로딩 함수 (leftItem이 null이면 전체 데이터 로드, page: 서버 페이징용)
|
||||
const loadTabData = useCallback(
|
||||
async (tabIndex: number, leftItem: any) => {
|
||||
async (tabIndex: number, leftItem: any, page?: number, pageSizeOverride?: number) => {
|
||||
const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1];
|
||||
if (!tabConfig || isDesignMode) return;
|
||||
|
||||
|
|
@ -1623,109 +1674,73 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const keys = tabConfig.relation?.keys;
|
||||
const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn;
|
||||
const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn;
|
||||
|
||||
// 탭 config의 Entity 조인 컬럼 추출
|
||||
const tabJoinColumns = extractAdditionalJoinColumns(tabConfig.columns, tabTableName);
|
||||
if (tabJoinColumns) {
|
||||
console.log(`🔗 [분할패널] 탭 ${tabIndex} additionalJoinColumns:`, tabJoinColumns);
|
||||
}
|
||||
|
||||
let resultData: any[] = [];
|
||||
|
||||
// 탭의 dataFilter (API 전달용)
|
||||
let apiResult: any = null;
|
||||
const tabDataFilterForApi = (tabConfig as any).dataFilter;
|
||||
|
||||
// 탭의 relation type 확인 (detail이면 초기 전체 로드 안 함)
|
||||
const tabRelationType = tabConfig.relation?.type || "join";
|
||||
|
||||
const tabPagState = tabsPagination[tabIndex];
|
||||
const currentTabPage = page ?? tabPagState?.currentPage ?? 1;
|
||||
const currentTabPageSize = pageSizeOverride ?? tabPagState?.pageSize ?? rightPageSize;
|
||||
const apiSize = rightPaginationEnabled ? currentTabPageSize : MAX_LOAD_ALL_SIZE;
|
||||
const apiPage = rightPaginationEnabled ? currentTabPage : undefined;
|
||||
|
||||
const commonApiParams = {
|
||||
enableEntityJoin: true,
|
||||
size: apiSize,
|
||||
page: apiPage,
|
||||
companyCodeOverride: companyCode,
|
||||
additionalJoinColumns: tabJoinColumns,
|
||||
dataFilter: tabDataFilterForApi,
|
||||
};
|
||||
|
||||
if (!leftItem) {
|
||||
if (tabRelationType === "detail") {
|
||||
// detail 모드: 선택 안 하면 아무것도 안 뜸
|
||||
resultData = [];
|
||||
} else {
|
||||
// join 모드: 좌측 미선택 시 전체 데이터 로드 (dataFilter는 API에 전달)
|
||||
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
|
||||
enableEntityJoin: true,
|
||||
size: 1000,
|
||||
companyCodeOverride: companyCode,
|
||||
additionalJoinColumns: tabJoinColumns,
|
||||
dataFilter: tabDataFilterForApi,
|
||||
});
|
||||
resultData = result.data || [];
|
||||
if (tabRelationType !== "detail") {
|
||||
apiResult = await entityJoinApi.getTableDataWithJoins(tabTableName, commonApiParams);
|
||||
resultData = apiResult.data || [];
|
||||
}
|
||||
} else if (leftColumn && rightColumn) {
|
||||
const searchConditions: Record<string, any> = {};
|
||||
|
||||
if (keys && keys.length > 0) {
|
||||
keys.forEach((key: any) => {
|
||||
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
|
||||
searchConditions[key.rightColumn] = {
|
||||
value: leftItem[key.leftColumn],
|
||||
operator: "equals",
|
||||
};
|
||||
searchConditions[key.rightColumn] = { value: leftItem[key.leftColumn], operator: "equals" };
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const leftValue = leftItem[leftColumn];
|
||||
if (leftValue !== undefined) {
|
||||
searchConditions[rightColumn] = {
|
||||
value: leftValue,
|
||||
operator: "equals",
|
||||
};
|
||||
searchConditions[rightColumn] = { value: leftValue, operator: "equals" };
|
||||
}
|
||||
}
|
||||
|
||||
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
|
||||
apiResult = await entityJoinApi.getTableDataWithJoins(tabTableName, {
|
||||
search: searchConditions,
|
||||
enableEntityJoin: true,
|
||||
size: 1000,
|
||||
companyCodeOverride: companyCode,
|
||||
additionalJoinColumns: tabJoinColumns,
|
||||
dataFilter: tabDataFilterForApi,
|
||||
...commonApiParams,
|
||||
});
|
||||
resultData = result.data || [];
|
||||
resultData = apiResult.data || [];
|
||||
} else {
|
||||
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
|
||||
enableEntityJoin: true,
|
||||
size: 1000,
|
||||
companyCodeOverride: companyCode,
|
||||
additionalJoinColumns: tabJoinColumns,
|
||||
dataFilter: tabDataFilterForApi,
|
||||
});
|
||||
resultData = result.data || [];
|
||||
apiResult = await entityJoinApi.getTableDataWithJoins(tabTableName, commonApiParams);
|
||||
resultData = apiResult.data || [];
|
||||
}
|
||||
|
||||
// 탭별 dataFilter 적용
|
||||
const tabDataFilter = (tabConfig as any).dataFilter;
|
||||
if (tabDataFilter?.enabled && tabDataFilter.filters?.length > 0) {
|
||||
resultData = resultData.filter((item: any) => {
|
||||
return tabDataFilter.filters.every((cond: any) => {
|
||||
const value = item[cond.columnName];
|
||||
switch (cond.operator) {
|
||||
case "equals":
|
||||
return value === cond.value;
|
||||
case "notEquals":
|
||||
case "not_equals":
|
||||
return value !== cond.value;
|
||||
case "in": {
|
||||
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
|
||||
return arr.includes(value);
|
||||
}
|
||||
case "not_in": {
|
||||
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
|
||||
return !arr.includes(value);
|
||||
}
|
||||
case "contains":
|
||||
return String(value || "").includes(String(cond.value));
|
||||
case "is_null":
|
||||
return value === null || value === undefined || value === "";
|
||||
case "is_not_null":
|
||||
return value !== null && value !== undefined && value !== "";
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
// 공통 페이징 상태 업데이트
|
||||
if (rightPaginationEnabled && apiResult) {
|
||||
setTabsPagination((prev) => ({
|
||||
...prev,
|
||||
[tabIndex]: {
|
||||
currentPage: apiResult.page || currentTabPage,
|
||||
totalPages: apiResult.totalPages || 1,
|
||||
total: apiResult.total || 0,
|
||||
pageSize: currentTabPageSize,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
if (!rightPaginationEnabled) {
|
||||
resultData = applyClientSideFilter(resultData, (tabConfig as any).dataFilter);
|
||||
}
|
||||
|
||||
setTabsData((prev) => ({ ...prev, [tabIndex]: resultData }));
|
||||
|
|
@ -1740,9 +1755,148 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
setTabsLoading((prev) => ({ ...prev, [tabIndex]: false }));
|
||||
}
|
||||
},
|
||||
[componentConfig.rightPanel?.additionalTabs, isDesignMode, toast],
|
||||
[componentConfig.rightPanel?.additionalTabs, rightPaginationEnabled, rightPageSize, tabsPagination, isDesignMode, toast],
|
||||
);
|
||||
|
||||
// 🆕 좌측 페이지 변경 핸들러
|
||||
const handleLeftPageChange = useCallback((newPage: number) => {
|
||||
if (newPage < 1 || newPage > leftTotalPages) return;
|
||||
setLeftCurrentPage(newPage);
|
||||
setLeftPageInput(String(newPage));
|
||||
loadLeftData(newPage);
|
||||
}, [leftTotalPages, loadLeftData]);
|
||||
|
||||
const commitLeftPageInput = useCallback(() => {
|
||||
const parsed = parseInt(leftPageInput, 10);
|
||||
if (!isNaN(parsed) && parsed >= 1 && parsed <= leftTotalPages) {
|
||||
handleLeftPageChange(parsed);
|
||||
} else {
|
||||
setLeftPageInput(String(leftCurrentPage));
|
||||
}
|
||||
}, [leftPageInput, leftTotalPages, leftCurrentPage, handleLeftPageChange]);
|
||||
|
||||
// 🆕 좌측 페이지 크기 변경
|
||||
const handleLeftPageSizeChange = useCallback((newSize: number) => {
|
||||
setLeftPageSize(newSize);
|
||||
setLeftCurrentPage(1);
|
||||
setLeftPageInput("1");
|
||||
loadLeftData(1, newSize);
|
||||
}, [loadLeftData]);
|
||||
|
||||
// 🆕 우측 페이지 변경 핸들러
|
||||
const handleRightPageChange = useCallback((newPage: number) => {
|
||||
if (newPage < 1 || newPage > rightTotalPages) return;
|
||||
setRightCurrentPage(newPage);
|
||||
setRightPageInput(String(newPage));
|
||||
if (activeTabIndex === 0) {
|
||||
loadRightData(selectedLeftItem, newPage);
|
||||
} else {
|
||||
loadTabData(activeTabIndex, selectedLeftItem, newPage);
|
||||
}
|
||||
}, [rightTotalPages, activeTabIndex, selectedLeftItem, loadRightData, loadTabData]);
|
||||
|
||||
const commitRightPageInput = useCallback(() => {
|
||||
const parsed = parseInt(rightPageInput, 10);
|
||||
const tp = activeTabIndex === 0 ? rightTotalPages : (tabsPagination[activeTabIndex]?.totalPages ?? 1);
|
||||
if (!isNaN(parsed) && parsed >= 1 && parsed <= tp) {
|
||||
handleRightPageChange(parsed);
|
||||
} else {
|
||||
const cp = activeTabIndex === 0 ? rightCurrentPage : (tabsPagination[activeTabIndex]?.currentPage ?? 1);
|
||||
setRightPageInput(String(cp));
|
||||
}
|
||||
}, [rightPageInput, rightTotalPages, rightCurrentPage, activeTabIndex, tabsPagination, handleRightPageChange]);
|
||||
|
||||
// 🆕 우측 페이지 크기 변경
|
||||
const handleRightPageSizeChange = useCallback((newSize: number) => {
|
||||
setRightPageSize(newSize);
|
||||
setRightCurrentPage(1);
|
||||
setRightPageInput("1");
|
||||
setTabsPagination({});
|
||||
if (activeTabIndex === 0) {
|
||||
loadRightData(selectedLeftItem, 1, newSize);
|
||||
} else {
|
||||
loadTabData(activeTabIndex, selectedLeftItem, 1, newSize);
|
||||
}
|
||||
}, [activeTabIndex, selectedLeftItem, loadRightData, loadTabData]);
|
||||
|
||||
// 🆕 페이징 UI 컴포넌트 (공통)
|
||||
const renderPaginationBar = useCallback((params: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
total: number;
|
||||
pageSize: number;
|
||||
pageInput: string;
|
||||
setPageInput: (v: string) => void;
|
||||
onPageChange: (p: number) => void;
|
||||
onPageSizeChange: (s: number) => void;
|
||||
commitPageInput: () => void;
|
||||
loading: boolean;
|
||||
}) => {
|
||||
const { currentPage, totalPages, total, pageSize, pageInput, setPageInput, onPageChange, onPageSizeChange, commitPageInput: commitFn, loading } = params;
|
||||
return (
|
||||
<div className="border-border bg-background relative flex h-10 w-full flex-shrink-0 items-center justify-center border-t px-2">
|
||||
<div className="absolute left-2 flex items-center gap-1">
|
||||
<span className="text-muted-foreground text-[10px]">표시:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_LOAD_ALL_SIZE}
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
const v = Math.min(MAX_LOAD_ALL_SIZE, Math.max(1, Number(e.target.value) || 1));
|
||||
onPageSizeChange(v);
|
||||
}}
|
||||
className="border-input bg-background focus:ring-ring h-6 w-12 rounded border px-1 text-center text-[10px] focus:ring-1 focus:outline-none"
|
||||
/>
|
||||
<span className="text-muted-foreground text-[10px]">/ {total}건</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="sm" onClick={() => onPageChange(1)} disabled={currentPage === 1 || loading} className="h-6 w-6 p-0">
|
||||
<ChevronsLeft className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onPageChange(currentPage - 1)} disabled={currentPage === 1 || loading} className="h-6 w-6 p-0">
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={pageInput}
|
||||
onChange={(e) => setPageInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { commitFn(); (e.target as HTMLInputElement).blur(); } }}
|
||||
onBlur={commitFn}
|
||||
onFocus={(e) => e.target.select()}
|
||||
disabled={loading}
|
||||
className="border-input bg-background focus:ring-ring h-6 w-8 rounded border px-1 text-center text-[10px] font-medium focus:ring-1 focus:outline-none"
|
||||
/>
|
||||
<span className="text-muted-foreground text-[10px]">/</span>
|
||||
<span className="text-foreground text-[10px] font-medium">{totalPages || 1}</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => onPageChange(currentPage + 1)} disabled={currentPage >= totalPages || loading} className="h-6 w-6 p-0">
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onPageChange(totalPages)} disabled={currentPage >= totalPages || loading} className="h-6 w-6 p-0">
|
||||
<ChevronsRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 우측/탭 페이징 상태 (IIFE 대신 useMemo로 사전 계산)
|
||||
const rightPagState = useMemo(() => {
|
||||
const isTab = activeTabIndex > 0;
|
||||
const tabPag = isTab ? tabsPagination[activeTabIndex] : null;
|
||||
return {
|
||||
isTab,
|
||||
currentPage: isTab ? (tabPag?.currentPage ?? 1) : rightCurrentPage,
|
||||
totalPages: isTab ? (tabPag?.totalPages ?? 1) : rightTotalPages,
|
||||
total: isTab ? (tabPag?.total ?? 0) : rightTotal,
|
||||
pageSize: isTab ? (tabPag?.pageSize ?? rightPageSize) : rightPageSize,
|
||||
};
|
||||
}, [activeTabIndex, tabsPagination, rightCurrentPage, rightTotalPages, rightTotal, rightPageSize]);
|
||||
|
||||
// 탭 변경 핸들러
|
||||
const handleTabChange = useCallback(
|
||||
(newTabIndex: number) => {
|
||||
|
|
@ -1779,12 +1933,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
selectedLeftItem[leftPk] === item[leftPk];
|
||||
|
||||
if (isSameItem) {
|
||||
// 선택 해제
|
||||
setSelectedLeftItem(null);
|
||||
setCustomLeftSelectedData({});
|
||||
setExpandedRightItems(new Set());
|
||||
setTabsData({});
|
||||
|
||||
// 우측/탭 페이지 리셋
|
||||
if (rightPaginationEnabled) {
|
||||
setRightCurrentPage(1);
|
||||
setRightPageInput("1");
|
||||
setTabsPagination({});
|
||||
}
|
||||
|
||||
const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail";
|
||||
if (mainRelationType === "detail") {
|
||||
// "선택 시 표시" 모드: 선택 해제 시 데이터 비움
|
||||
|
|
@ -1809,15 +1969,21 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}
|
||||
|
||||
setSelectedLeftItem(item);
|
||||
setCustomLeftSelectedData(item); // 커스텀 모드 우측 폼에 선택된 데이터 전달
|
||||
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
|
||||
setTabsData({}); // 모든 탭 데이터 초기화
|
||||
setCustomLeftSelectedData(item);
|
||||
setExpandedRightItems(new Set());
|
||||
setTabsData({});
|
||||
|
||||
// 우측/탭 페이지 리셋
|
||||
if (rightPaginationEnabled) {
|
||||
setRightCurrentPage(1);
|
||||
setRightPageInput("1");
|
||||
setTabsPagination({});
|
||||
}
|
||||
|
||||
// 현재 활성 탭에 따라 데이터 로드
|
||||
if (activeTabIndex === 0) {
|
||||
loadRightData(item);
|
||||
loadRightData(item, 1);
|
||||
} else {
|
||||
loadTabData(activeTabIndex, item);
|
||||
loadTabData(activeTabIndex, item, 1);
|
||||
}
|
||||
|
||||
// modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
|
||||
|
|
@ -1829,7 +1995,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
});
|
||||
}
|
||||
},
|
||||
[loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation, componentConfig.rightPanel?.additionalTabs, isDesignMode, selectedLeftItem],
|
||||
[loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation, componentConfig.rightPanel?.additionalTabs, isDesignMode, selectedLeftItem, rightPaginationEnabled],
|
||||
);
|
||||
|
||||
// 우측 항목 확장/축소 토글
|
||||
|
|
@ -3104,10 +3270,30 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDesignMode, componentConfig.autoLoad]);
|
||||
|
||||
// 🔄 필터 변경 시 데이터 다시 로드
|
||||
// config에서 pageSize 변경 시 상태 동기화 + 페이지 리셋
|
||||
useEffect(() => {
|
||||
const configLeftPageSize = componentConfig.leftPanel?.pagination?.pageSize ?? 20;
|
||||
setLeftPageSize(configLeftPageSize);
|
||||
setLeftCurrentPage(1);
|
||||
setLeftPageInput("1");
|
||||
}, [componentConfig.leftPanel?.pagination?.pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
const configRightPageSize = componentConfig.rightPanel?.pagination?.pageSize ?? 20;
|
||||
setRightPageSize(configRightPageSize);
|
||||
setRightCurrentPage(1);
|
||||
setRightPageInput("1");
|
||||
setTabsPagination({});
|
||||
}, [componentConfig.rightPanel?.pagination?.pageSize]);
|
||||
|
||||
// 🔄 필터 변경 시 데이터 다시 로드 (페이지 1로 리셋)
|
||||
useEffect(() => {
|
||||
if (!isDesignMode && componentConfig.autoLoad !== false) {
|
||||
loadLeftData();
|
||||
if (leftPaginationEnabled) {
|
||||
setLeftCurrentPage(1);
|
||||
setLeftPageInput("1");
|
||||
}
|
||||
loadLeftData(1);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leftFilters]);
|
||||
|
|
@ -3547,12 +3733,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
format: undefined, // 🆕 기본값
|
||||
}));
|
||||
|
||||
// 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤)
|
||||
const leftTotalColWidth = columnsToShow.reduce((sum, col) => {
|
||||
const w = col.width && col.width <= 100 ? col.width : 0;
|
||||
return sum + w;
|
||||
}, 0);
|
||||
|
||||
// 🔧 그룹화된 데이터 렌더링
|
||||
const hasGroupedLeftActions = !isDesignMode && (
|
||||
(componentConfig.leftPanel?.showEdit !== false) ||
|
||||
|
|
@ -3566,7 +3746,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
<div className="bg-muted px-3 py-2 text-sm font-semibold">
|
||||
{group.groupKey} ({group.count}개)
|
||||
</div>
|
||||
<table className="divide-y divide-border table-fixed" style={{ width: leftTotalColWidth > 100 ? `${leftTotalColWidth}%` : '100%' }}>
|
||||
<table className="min-w-full divide-y divide-border">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
{columnsToShow.map((col, idx) => (
|
||||
|
|
@ -3574,7 +3754,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
key={idx}
|
||||
className="px-3 py-2 text-left text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap"
|
||||
style={{
|
||||
width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
|
||||
minWidth: col.width ? `${col.width}px` : "80px",
|
||||
textAlign: col.align || "left",
|
||||
}}
|
||||
>
|
||||
|
|
@ -3663,7 +3843,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
);
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<table className="divide-y divide-border table-fixed" style={{ width: leftTotalColWidth > 100 ? `${leftTotalColWidth}%` : '100%' }}>
|
||||
<table className="min-w-full divide-y divide-border">
|
||||
<thead className="sticky top-0 z-10 bg-muted">
|
||||
<tr>
|
||||
{columnsToShow.map((col, idx) => (
|
||||
|
|
@ -3671,7 +3851,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
key={idx}
|
||||
className="px-3 py-2 text-left text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap"
|
||||
style={{
|
||||
width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
|
||||
minWidth: col.width ? `${col.width}px` : "80px",
|
||||
textAlign: col.align || "left",
|
||||
}}
|
||||
>
|
||||
|
|
@ -4008,6 +4188,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* 좌측 페이징 UI */}
|
||||
{leftPaginationEnabled && !isDesignMode && (
|
||||
renderPaginationBar({
|
||||
currentPage: leftCurrentPage,
|
||||
totalPages: leftTotalPages,
|
||||
total: leftTotal,
|
||||
pageSize: leftPageSize,
|
||||
pageInput: leftPageInput,
|
||||
setPageInput: setLeftPageInput,
|
||||
onPageChange: handleLeftPageChange,
|
||||
onPageSizeChange: handleLeftPageSizeChange,
|
||||
commitPageInput: commitLeftPageInput,
|
||||
loading: isLoadingLeft,
|
||||
})
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
|
@ -4666,16 +4862,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}));
|
||||
}
|
||||
|
||||
// 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤)
|
||||
const rightTotalColWidth = columnsToShow.reduce((sum, col) => {
|
||||
const w = col.width && col.width <= 100 ? col.width : 0;
|
||||
return sum + w;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<table className="table-fixed" style={{ width: rightTotalColWidth > 100 ? `${rightTotalColWidth}%` : '100%' }}>
|
||||
<table className="min-w-full">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="border-b-2 border-border/60">
|
||||
{columnsToShow.map((col, idx) => (
|
||||
|
|
@ -4683,7 +4873,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
key={idx}
|
||||
className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap"
|
||||
style={{
|
||||
width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
|
||||
minWidth: col.width ? `${col.width}px` : "80px",
|
||||
textAlign: col.align || "left",
|
||||
}}
|
||||
>
|
||||
|
|
@ -4796,12 +4986,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}));
|
||||
}
|
||||
|
||||
// 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤)
|
||||
const displayTotalColWidth = columnsToDisplay.reduce((sum, col) => {
|
||||
const w = col.width && col.width <= 100 ? col.width : 0;
|
||||
return sum + w;
|
||||
}, 0);
|
||||
|
||||
const hasEditButton = !isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true);
|
||||
const hasDeleteButton = !isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true);
|
||||
const hasActions = hasEditButton || hasDeleteButton;
|
||||
|
|
@ -4809,14 +4993,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
return filteredData.length > 0 ? (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<table className="table-fixed text-sm" style={{ width: displayTotalColWidth > 100 ? `${displayTotalColWidth}%` : '100%' }}>
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-background">
|
||||
<tr className="border-b-2 border-border/60">
|
||||
{columnsToDisplay.map((col) => (
|
||||
<th
|
||||
key={col.name}
|
||||
className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap"
|
||||
style={{ width: col.width && col.width <= 100 ? `${col.width}%` : "auto" }}
|
||||
style={{ minWidth: col.width ? `${col.width}px` : "80px" }}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
|
|
@ -5040,6 +5224,31 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* 우측/탭 페이징 UI */}
|
||||
{rightPaginationEnabled && !isDesignMode && renderPaginationBar({
|
||||
currentPage: rightPagState.currentPage,
|
||||
totalPages: rightPagState.totalPages,
|
||||
total: rightPagState.total,
|
||||
pageSize: rightPagState.pageSize,
|
||||
pageInput: rightPageInput,
|
||||
setPageInput: setRightPageInput,
|
||||
onPageChange: (p) => {
|
||||
if (rightPagState.isTab) {
|
||||
setTabsPagination((prev) => ({
|
||||
...prev,
|
||||
[activeTabIndex]: { ...(prev[activeTabIndex] || { currentPage: 1, totalPages: 1, total: 0, pageSize: rightPageSize }), currentPage: p },
|
||||
}));
|
||||
setRightPageInput(String(p));
|
||||
loadTabData(activeTabIndex, selectedLeftItem, p);
|
||||
} else {
|
||||
handleRightPageChange(p);
|
||||
}
|
||||
},
|
||||
onPageSizeChange: handleRightPageSizeChange,
|
||||
commitPageInput: commitRightPageInput,
|
||||
loading: isLoadingRight || (tabsLoading[activeTabIndex] ?? false),
|
||||
})}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,15 @@ import { DataFilterConfig, TabInlineComponent } from "@/types/screen-management"
|
|||
*/
|
||||
export type PanelInlineComponent = TabInlineComponent;
|
||||
|
||||
/** 페이징 처리 설정 (좌측/우측 패널 공통) */
|
||||
export interface PaginationConfig {
|
||||
enabled: boolean;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/** 페이징 OFF 시 전체 데이터 로드에 사용하는 최대 건수 */
|
||||
export const MAX_LOAD_ALL_SIZE = 10000;
|
||||
|
||||
/**
|
||||
* 추가 탭 설정 (우측 패널과 동일한 구조 + tabId, label)
|
||||
*/
|
||||
|
|
@ -224,6 +233,8 @@ export interface SplitPanelLayoutConfig {
|
|||
|
||||
// 🆕 컬럼 값 기반 데이터 필터링
|
||||
dataFilter?: DataFilterConfig;
|
||||
|
||||
pagination?: PaginationConfig;
|
||||
};
|
||||
|
||||
// 우측 패널 설정
|
||||
|
|
@ -351,6 +362,8 @@ export interface SplitPanelLayoutConfig {
|
|||
|
||||
// 🆕 추가 탭 설정 (멀티 테이블 탭)
|
||||
additionalTabs?: AdditionalTabConfig[];
|
||||
|
||||
pagination?: PaginationConfig;
|
||||
};
|
||||
|
||||
// 레이아웃 설정
|
||||
|
|
|
|||
|
|
@ -798,6 +798,25 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
minHeight: "48px",
|
||||
}}
|
||||
>
|
||||
{/* 테이블 선택 드롭다운 (여러 테이블 + showTableSelector 활성 시) */}
|
||||
{showTableSelector && hasMultipleTables && (
|
||||
<Select
|
||||
value={selectedTableId || ""}
|
||||
onValueChange={(value) => setSelectedTableId(value)}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full shrink-0 text-xs sm:w-[200px] sm:text-sm">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableList.map((table) => (
|
||||
<SelectItem key={table.tableId} value={table.tableId}>
|
||||
{table.label || table.tableName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* 필터 입력 필드들 */}
|
||||
{activeFilters.length > 0 && (
|
||||
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
|
|
|
|||
|
|
@ -6566,7 +6566,36 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
|
||||
// dataTransfer 설정이 있는 경우
|
||||
const { targetType, targetComponentId, targetScreenId, mappingRules, receiveMode } = dataTransfer;
|
||||
const { targetType, targetComponentId, targetScreenId, receiveMode } = dataTransfer;
|
||||
|
||||
// multiTableMappings 우선: 소스 테이블에 맞는 매핑 규칙 선택
|
||||
let mappingRules = dataTransfer.mappingRules;
|
||||
const multiTableMappings = (dataTransfer as any).multiTableMappings as Array<{
|
||||
sourceTable: string;
|
||||
mappingRules: Array<{ sourceField: string; targetField: string }>;
|
||||
}> | undefined;
|
||||
|
||||
if (multiTableMappings && multiTableMappings.length > 0) {
|
||||
const sourceTableName = context.tableName || (dataTransfer as any).sourceTable;
|
||||
const matchedGroup = multiTableMappings.find((g) => g.sourceTable === sourceTableName);
|
||||
if (matchedGroup && matchedGroup.mappingRules?.length > 0) {
|
||||
mappingRules = matchedGroup.mappingRules;
|
||||
console.log("📋 [transferData] multiTableMappings 매핑 적용:", {
|
||||
sourceTable: sourceTableName,
|
||||
rules: matchedGroup.mappingRules,
|
||||
});
|
||||
} else if (!mappingRules || mappingRules.length === 0) {
|
||||
// 매칭되는 그룹이 없고 기존 mappingRules도 없으면 첫 번째 그룹 사용
|
||||
const fallback = multiTableMappings[0];
|
||||
if (fallback?.mappingRules?.length > 0) {
|
||||
mappingRules = fallback.mappingRules;
|
||||
console.log("📋 [transferData] multiTableMappings 폴백 매핑 적용:", {
|
||||
sourceTable: fallback.sourceTable,
|
||||
rules: fallback.mappingRules,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetType === "component" && targetComponentId) {
|
||||
// 같은 화면 내 컴포넌트로 전달 + 레이어 활성화 이벤트 병행
|
||||
|
|
|
|||
|
|
@ -266,6 +266,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
|
|
@ -307,6 +308,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -340,6 +342,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
|
|
@ -3055,6 +3058,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
|
||||
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.17.8",
|
||||
"@types/react-reconciler": "^0.32.0",
|
||||
|
|
@ -3708,6 +3712,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
|
||||
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.6"
|
||||
},
|
||||
|
|
@ -3802,6 +3807,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
||||
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
|
|
@ -4115,6 +4121,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
|
||||
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
"prosemirror-collab": "^1.3.1",
|
||||
|
|
@ -6615,6 +6622,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
|
|
@ -6625,6 +6633,7 @@
|
|||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
|
|
@ -6667,6 +6676,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
|
|
@ -6749,6 +6759,7 @@
|
|||
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.2",
|
||||
"@typescript-eslint/types": "8.46.2",
|
||||
|
|
@ -7381,6 +7392,7 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -8531,7 +8543,8 @@
|
|||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "7.9.0",
|
||||
|
|
@ -8853,6 +8866,7 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -9612,6 +9626,7 @@
|
|||
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
|
|
@ -9700,6 +9715,7 @@
|
|||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
|
|
@ -9801,6 +9817,7 @@
|
|||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
|
|
@ -10972,6 +10989,7 @@
|
|||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
|
|
@ -11752,7 +11770,8 @@
|
|||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
|
|
@ -13091,6 +13110,7 @@
|
|||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
|
|
@ -13384,6 +13404,7 @@
|
|||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
|
|
@ -13413,6 +13434,7 @@
|
|||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
|
|
@ -13461,6 +13483,7 @@
|
|||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
|
||||
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
|
|
@ -13664,6 +13687,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -13733,6 +13757,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
|
|
@ -13783,6 +13808,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
||||
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
|
|
@ -13815,7 +13841,8 @@
|
|||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
|
|
@ -14123,6 +14150,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
|
|
@ -14145,7 +14173,8 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/recharts/node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
|
|
@ -15175,7 +15204,8 @@
|
|||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/three-mesh-bvh": {
|
||||
"version": "0.8.3",
|
||||
|
|
@ -15263,6 +15293,7 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -15611,6 +15642,7 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
|
|||
Loading…
Reference in New Issue