jskim-node #419

Merged
kjs merged 15 commits from jskim-node into main 2026-03-17 09:56:34 +09:00
14 changed files with 2129 additions and 1288 deletions
Showing only changes of commit 4e65af4919 - Show all commits

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1155,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"];

View File

@ -102,12 +102,16 @@ function mergeColumnMeta(tableName: string | undefined, columnName: string | und
if (dbInputType === "entity") {
const refTable = meta.reference_table || meta.referenceTable;
const refColumn = meta.reference_column || meta.referenceColumn;
const displayCol = meta.display_column || meta.displayColumn;
const rawDisplayCol = meta.display_column || meta.displayColumn;
const displayCol = rawDisplayCol && rawDisplayCol !== "none" && rawDisplayCol !== "" ? rawDisplayCol : undefined;
if (refTable) {
merged.source = "entity";
merged.entityTable = refTable;
merged.entityValueColumn = refColumn || "id";
merged.entityLabelColumn = displayCol || "name";
// 화면 설정에 이미 entityLabelColumn이 있으면 유지, 없으면 DB 값 또는 기본값 사용
if (!merged.entityLabelColumn) {
merged.entityLabelColumn = displayCol || "name";
}
merged.fieldType = "entity";
merged.inputType = "entity";
}

View File

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

View File

@ -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"