diff --git a/.gitignore b/.gitignore index 18a191ed..59dd321e 100644 --- a/.gitignore +++ b/.gitignore @@ -293,4 +293,14 @@ claude.md # 개인 작업 문서 (popdocs) popdocs/ -.cursor/rules/popdocs-safety.mdc \ No newline at end of file +.cursor/rules/popdocs-safety.mdc + +# ============================================ +# KSH 개인 오케스트레이션 설정 (팀 공유 안함) +# ============================================ +.cursor/rules/orchestrator.mdc +.cursor/agents/ +.cursor/commands/ +.cursor/hooks.json +.cursor/hooks/ +.cursor/plans/ \ No newline at end of file diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx index 42b1ee06..dcd1d987 100644 --- a/frontend/components/pop/designer/panels/ComponentPalette.tsx +++ b/frontend/components/pop/designer/panels/ComponentPalette.tsx @@ -3,7 +3,7 @@ import { useDrag } from "react-dnd"; import { cn } from "@/lib/utils"; import { PopComponentType } from "../types/pop-layout"; -import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search } from "lucide-react"; +import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput } from "lucide-react"; import { DND_ITEM_TYPES } from "../constants"; // 컴포넌트 정의 @@ -63,6 +63,12 @@ const PALETTE_ITEMS: PaletteItem[] = [ icon: Search, description: "조건 입력 (텍스트/날짜/선택/모달)", }, + { + type: "pop-field", + label: "입력 필드", + icon: TextCursorInput, + description: "저장용 값 입력 (섹션별 멀티필드)", + }, ]; // 드래그 가능한 컴포넌트 아이템 diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index 88100d27..32ac610b 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -75,6 +75,7 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-button": "버튼", "pop-string-list": "리스트 목록", "pop-search": "검색", + "pop-field": "입력", }; // ======================================== diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index 15e70c65..a02bf02b 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -9,7 +9,7 @@ /** * POP 컴포넌트 타입 */ -export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search"; +export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search" | "pop-field"; /** * 데이터 흐름 정의 @@ -361,6 +361,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record(null); + + const [allValues, setAllValues] = useState>({}); + const [hiddenValues, setHiddenValues] = useState>({}); + const [errors, setErrors] = useState>({}); + const [containerWidth, setContainerWidth] = useState(0); + + const hiddenMappings = cfg.saveConfig?.hiddenMappings ?? []; + const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? []; + const visibleAutoGens = autoGenMappings.filter((m) => m.showInForm); + + // ResizeObserver로 컨테이너 너비 감시 + useEffect(() => { + if (typeof window === "undefined" || !containerRef.current) return; + const ro = new ResizeObserver(([entry]) => { + setContainerWidth(entry.contentRect.width); + }); + ro.observe(containerRef.current); + return () => ro.disconnect(); + }, []); + + // readSource 기반 DB 조회 + JSON 파싱 + const fetchReadSourceData = useCallback( + async (pkValue: unknown, readSource: PopFieldReadSource) => { + if (!readSource.tableName || !readSource.pkColumn || !pkValue) return; + try { + const res = await dataApi.getTableData(readSource.tableName, { + page: 1, + size: 1, + filters: { [readSource.pkColumn]: String(pkValue) }, + }); + if (!Array.isArray(res.data) || res.data.length === 0) return; + const row = res.data[0] as Record; + + const extracted: Record = {}; + for (const mapping of readSource.fieldMappings || []) { + if (mapping.valueSource === "json_extract" && mapping.columnName && mapping.jsonKey) { + const raw = row[mapping.columnName]; + let parsed: Record = {}; + if (typeof raw === "string") { + try { parsed = JSON.parse(raw); } catch { /* ignore */ } + } else if (typeof raw === "object" && raw !== null) { + parsed = raw as Record; + } + extracted[mapping.fieldId] = parsed[mapping.jsonKey] ?? ""; + } else if (mapping.valueSource === "db_column" && mapping.columnName) { + extracted[mapping.fieldId] = row[mapping.columnName] ?? ""; + } + } + + const allFieldsInConfig = cfg.sections.flatMap((s) => s.fields || []); + const valuesUpdate: Record = {}; + for (const [fieldId, val] of Object.entries(extracted)) { + const f = allFieldsInConfig.find((fi) => fi.id === fieldId); + const key = f?.fieldName || f?.id || fieldId; + valuesUpdate[key] = val; + } + if (Object.keys(valuesUpdate).length > 0) { + setAllValues((prev) => ({ ...prev, ...valuesUpdate })); + } + } catch { + // 조회 실패 시 무시 + } + }, + [cfg.sections] + ); + + // set_value 이벤트 수신 (useConnectionResolver의 enrichedPayload도 처리) + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__set_value`, + (payload: unknown) => { + const raw = payload as Record | undefined; + if (!raw) return; + + // useConnectionResolver가 감싼 enrichedPayload인지 확인 + const isConnectionPayload = raw._connectionId !== undefined; + const actual = isConnectionPayload + ? (raw.value as Record | undefined) + : raw; + if (!actual) return; + + const data = actual as { + fieldName?: string; + value?: unknown; + values?: Record; + pkValue?: unknown; + }; + + // row 객체가 통째로 온 경우 (pop-card-list selected_row 등) + if (!data.fieldName && !data.values && !data.pkValue && typeof actual === "object") { + const rowObj = actual as Record; + setAllValues((prev) => ({ ...prev, ...rowObj })); + // 숨은 필드 값 추출 (valueSource 기반) + if (hiddenMappings.length > 0) { + const extracted: Record = {}; + for (const hm of hiddenMappings) { + if (hm.valueSource === "db_column" && hm.sourceDbColumn) { + if (rowObj[hm.sourceDbColumn] !== undefined) { + extracted[hm.targetColumn] = rowObj[hm.sourceDbColumn]; + } + } else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) { + const raw = rowObj[hm.sourceJsonColumn]; + let parsed: Record = {}; + if (typeof raw === "string") { + try { parsed = JSON.parse(raw); } catch { /* ignore */ } + } else if (typeof raw === "object" && raw !== null) { + parsed = raw as Record; + } + if (parsed[hm.sourceJsonKey] !== undefined) { + extracted[hm.targetColumn] = parsed[hm.sourceJsonKey]; + } + } + } + if (Object.keys(extracted).length > 0) { + setHiddenValues((prev) => ({ ...prev, ...extracted })); + } + } + const pkCol = cfg.readSource?.pkColumn; + const pkVal = pkCol ? rowObj[pkCol] : undefined; + if (pkVal && cfg.readSource) { + fetchReadSourceData(pkVal, cfg.readSource); + } + return; + } + + if (data.values) { + setAllValues((prev) => ({ ...prev, ...data.values })); + } else if (data.fieldName) { + setAllValues((prev) => ({ + ...prev, + [data.fieldName!]: data.value, + })); + } + if (data.pkValue && cfg.readSource) { + fetchReadSourceData(data.pkValue, cfg.readSource); + } + } + ); + return unsub; + }, [componentId, subscribe, cfg.readSource, fetchReadSourceData]); + + // 필드 값 변경 핸들러 + const handleFieldChange = useCallback( + (fieldName: string, value: unknown) => { + setAllValues((prev) => { + const next = { ...prev, [fieldName]: value }; + if (componentId) { + publish(`__comp_output__${componentId}__value_changed`, { + fieldName, + value, + allValues: next, + hiddenValues, + targetTable: cfg.saveConfig?.tableName || cfg.targetTable, + saveConfig: cfg.saveConfig, + readSource: cfg.readSource, + }); + } + return next; + }); + setErrors((prev) => { + if (!prev[fieldName]) return prev; + const next = { ...prev }; + delete next[fieldName]; + return next; + }); + }, + [componentId, publish, cfg.targetTable, cfg.saveConfig, cfg.readSource, hiddenValues] + ); + + // readSource 설정 시 자동 샘플 데이터 조회 (디자인 모드 미리보기) + const readSourceKey = cfg.readSource + ? `${cfg.readSource.tableName}__${cfg.readSource.pkColumn}__${(cfg.readSource.fieldMappings || []).map((m) => `${m.fieldId}:${m.columnName}:${m.jsonKey || ""}`).join(",")}` + : ""; + const previewFetchedRef = useRef(""); + useEffect(() => { + if (!cfg.readSource?.tableName || !cfg.readSource.fieldMappings?.length) return; + if (previewFetchedRef.current === readSourceKey) return; + previewFetchedRef.current = readSourceKey; + + (async () => { + try { + const res = await dataApi.getTableData(cfg.readSource!.tableName, { + page: 1, + size: 1, + }); + if (!Array.isArray(res.data) || res.data.length === 0) return; + const row = res.data[0] as Record; + + const extracted: Record = {}; + for (const mapping of cfg.readSource!.fieldMappings || []) { + if (mapping.valueSource === "json_extract" && mapping.columnName && mapping.jsonKey) { + const rawVal = row[mapping.columnName]; + let parsed: Record = {}; + if (typeof rawVal === "string") { + try { parsed = JSON.parse(rawVal); } catch { /* ignore */ } + } else if (typeof rawVal === "object" && rawVal !== null) { + parsed = rawVal as Record; + } + extracted[mapping.fieldId] = parsed[mapping.jsonKey] ?? ""; + } else if (mapping.valueSource === "db_column" && mapping.columnName) { + extracted[mapping.fieldId] = row[mapping.columnName] ?? ""; + } + } + + const allFieldsInConfig = cfg.sections.flatMap((s) => s.fields || []); + const valuesUpdate: Record = {}; + for (const [fieldId, val] of Object.entries(extracted)) { + const f = allFieldsInConfig.find((fi) => fi.id === fieldId); + const key = f?.fieldName || f?.id || fieldId; + valuesUpdate[key] = val; + } + if (Object.keys(valuesUpdate).length > 0) { + setAllValues((prev) => ({ ...prev, ...valuesUpdate })); + } + } catch { + // 미리보기 조회 실패 시 무시 + } + })(); + }, [readSourceKey, cfg.readSource, cfg.sections]); + + // "auto" 열 수 계산 + function resolveColumns( + columns: "auto" | 1 | 2 | 3 | 4, + fieldCount: number + ): number { + if (columns !== "auto") return columns; + if (containerWidth < 200) return 1; + if (containerWidth < 400) return Math.min(2, fieldCount); + if (containerWidth < 600) return Math.min(3, fieldCount); + return Math.min(4, fieldCount); + } + + function migrateStyle(style: string): FieldSectionStyle { + if (style === "display" || style === "input") return style; + if (style === "summary") return "display"; + if (style === "form") return "input"; + return "input"; + } + + function sectionClassName(section: PopFieldSection): string { + const resolved = migrateStyle(section.style); + const defaults = DEFAULT_SECTION_APPEARANCES[resolved]; + const a = section.appearance || {}; + const bg = a.bgColor || defaults.bgColor; + const border = a.borderColor || defaults.borderColor; + return cn("rounded-lg border px-4", bg, border, resolved === "display" ? "py-2" : "py-3"); + } + + return ( +
+ {cfg.sections.map((section) => { + const fields = section.fields || []; + const fieldCount = fields.length; + if (fieldCount === 0) return null; + const cols = resolveColumns(section.columns, fieldCount); + return ( +
+ {section.label && ( +
+ {section.label} +
+ )} +
+ {fields.map((field) => { + const fKey = field.fieldName || field.id; + return ( + + ); + })} +
+
+ ); + })} + {visibleAutoGens.length > 0 && ( +
+
+ {visibleAutoGens.map((ag) => ( + + ))} +
+
+ )} +
+ ); +} + +// ======================================== +// FieldRenderer: 개별 필드 렌더링 +// ======================================== + +interface FieldRendererProps { + field: PopFieldItem; + value: unknown; + showLabel: boolean; + error?: string; + onChange: (fieldName: string, value: unknown) => void; + sectionStyle: FieldSectionStyle; +} + +function FieldRenderer({ + field, + value, + showLabel, + error, + onChange, + sectionStyle, +}: FieldRendererProps) { + const handleChange = useCallback( + (v: unknown) => onChange(field.fieldName, v), + [onChange, field.fieldName] + ); + + const resolvedStyle = sectionStyle === "summary" ? "display" : sectionStyle === "form" ? "input" : sectionStyle; + const inputClassName = cn( + "h-9 w-full rounded-md border px-3 text-sm", + field.readOnly + ? "cursor-default bg-muted text-muted-foreground" + : "bg-background", + resolvedStyle === "display" && + "border-transparent bg-transparent text-sm font-medium" + ); + + return ( +
+ {showLabel && field.labelText && ( + + )} + {renderByType(field, value, handleChange, inputClassName)} + {error &&

{error}

} +
+ ); +} + +// ======================================== +// 서브타입별 렌더링 분기 +// ======================================== + +function renderByType( + field: PopFieldItem, + value: unknown, + onChange: (v: unknown) => void, + className: string +) { + switch (field.inputType) { + case "text": + return ( + onChange(e.target.value)} + readOnly={field.readOnly} + placeholder={field.placeholder} + className={className} + /> + ); + case "number": + return ( + + ); + case "date": + return ( + onChange(e.target.value)} + readOnly={field.readOnly} + className={className} + /> + ); + case "select": + return ( + + ); + case "auto": + return ; + case "numpad": + return ( + + ); + default: + return ( + + ); + } +} + +// ======================================== +// NumberFieldInput +// ======================================== + +function NumberFieldInput({ + field, + value, + onChange, + className, +}: { + field: PopFieldItem; + value: unknown; + onChange: (v: unknown) => void; + className: string; +}) { + return ( +
+ { + const num = e.target.value === "" ? "" : Number(e.target.value); + onChange(num); + }} + readOnly={field.readOnly} + placeholder={field.placeholder} + min={field.validation?.min} + max={field.validation?.max} + className={cn(className, "flex-1")} + /> + {field.unit && ( + + {field.unit} + + )} +
+ ); +} + +// ======================================== +// SelectFieldInput +// ======================================== + +function SelectFieldInput({ + field, + value, + onChange, + className, +}: { + field: PopFieldItem; + value: unknown; + onChange: (v: unknown) => void; + className: string; +}) { + const [options, setOptions] = useState<{ value: string; label: string }[]>( + [] + ); + const [loading, setLoading] = useState(false); + + const source = field.selectSource; + + useEffect(() => { + if (!source) return; + + if (source.type === "static" && source.staticOptions) { + setOptions(source.staticOptions); + return; + } + + if ( + source.type === "table" && + source.tableName && + source.valueColumn && + source.labelColumn + ) { + setLoading(true); + dataApi + .getTableData(source.tableName, { + page: 1, + pageSize: 500, + sortColumn: source.labelColumn, + sortDirection: "asc", + }) + .then((res) => { + if (res.data?.success && Array.isArray(res.data.data?.data)) { + setOptions( + res.data.data.data.map((row: Record) => ({ + value: String(row[source.valueColumn!] ?? ""), + label: String(row[source.labelColumn!] ?? ""), + })) + ); + } + }) + .catch(() => { + setOptions([]); + }) + .finally(() => setLoading(false)); + } + }, [source?.type, source?.tableName, source?.valueColumn, source?.labelColumn, source?.staticOptions]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (field.readOnly) { + const selectedLabel = + options.find((o) => o.value === String(value ?? ""))?.label ?? + String(value ?? "-"); + return ( + + ); + } + + if (!source) { + return ( +
+ 옵션 소스를 설정해주세요 +
+ ); + } + + return ( + + ); +} + +// ======================================== +// AutoFieldInput (자동 채번 - 읽기전용) +// ======================================== + +function AutoFieldInput({ + field, + value, + className, +}: { + field: PopFieldItem; + value: unknown; + className: string; +}) { + const displayValue = useMemo(() => { + if (value) return String(value); + if (!field.autoNumber) return "자동생성"; + + const { prefix, separator, dateFormat, sequenceDigits } = field.autoNumber; + const parts: string[] = []; + if (prefix) parts.push(prefix); + if (dateFormat) { + const now = new Date(); + const dateStr = dateFormat + .replace("YYYY", String(now.getFullYear())) + .replace("MM", String(now.getMonth() + 1).padStart(2, "0")) + .replace("DD", String(now.getDate()).padStart(2, "0")); + parts.push(dateStr); + } + if (sequenceDigits) { + parts.push("0".repeat(sequenceDigits)); + } + return parts.join(separator || "-") || "자동생성"; + }, [value, field.autoNumber]); + + return ( + + ); +} + +// ======================================== +// AutoGenFieldDisplay (자동생성 필드 - showInForm일 때 표시) +// ======================================== + +function AutoGenFieldDisplay({ mapping }: { mapping: PopFieldAutoGenMapping }) { + return ( +
+ {mapping.label && ( + + )} +
+ + 저장 시 자동발급 + +
+
+ ); +} + +// ======================================== +// NumpadFieldInput (클릭 시 숫자 직접 입력) +// ======================================== + +function NumpadFieldInput({ + field, + value, + onChange, + className, +}: { + field: PopFieldItem; + value: unknown; + onChange: (v: unknown) => void; + className: string; +}) { + const displayValue = + value !== undefined && value !== null ? String(value) : ""; + + return ( +
+ { + const num = e.target.value === "" ? "" : Number(e.target.value); + onChange(num); + }} + readOnly={field.readOnly} + placeholder={field.placeholder || "수량 입력"} + className={cn(className, "flex-1")} + /> + {field.unit && ( + + {field.unit} + + )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx b/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx new file mode 100644 index 00000000..4a285f20 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx @@ -0,0 +1,2220 @@ +"use client"; + +/** + * pop-field 설정 패널 + * + * 구조: + * - [레이아웃 탭] 섹션 목록 (추가/삭제/이동) + 필드 편집 + * - [저장 탭] 저장 테이블 / 필드-컬럼 매핑 / 읽기 데이터 소스 + */ + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { + ChevronDown, + ChevronRight, + Plus, + Trash2, + GripVertical, + Check, + ChevronsUpDown, +} 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 { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; +import type { + PopFieldConfig, + PopFieldSection, + PopFieldItem, + FieldInputType, + FieldSectionStyle, + FieldSectionAppearance, + FieldSelectSource, + AutoNumberConfig, + FieldValueSource, + PopFieldSaveMapping, + PopFieldSaveConfig, + PopFieldReadMapping, + PopFieldHiddenMapping, + PopFieldAutoGenMapping, + HiddenValueSource, +} from "./types"; +import { + DEFAULT_FIELD_CONFIG, + DEFAULT_SECTION_APPEARANCES, + FIELD_INPUT_TYPE_LABELS, + FIELD_SECTION_STYLE_LABELS, +} from "./types"; +import { + fetchTableList, + fetchTableColumns, + type TableInfo, + type ColumnInfo, +} from "../pop-dashboard/utils/dataFetcher"; +import { dataApi } from "@/lib/api/data"; +import { getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule"; + +// ======================================== +// Props +// ======================================== + +interface PopFieldConfigPanelProps { + config: PopFieldConfig | undefined; + onUpdate: (config: PopFieldConfig) => void; +} + +// ======================================== +// 메인 설정 패널 +// ======================================== + +export function PopFieldConfigPanel({ + config, + onUpdate: onConfigChange, +}: PopFieldConfigPanelProps) { + const cfg: PopFieldConfig = { + ...DEFAULT_FIELD_CONFIG, + ...config, + sections: config?.sections?.length ? config.sections : DEFAULT_FIELD_CONFIG.sections, + }; + const [tables, setTables] = useState([]); + const [saveTableOpen, setSaveTableOpen] = useState(false); + const [readTableOpen, setReadTableOpen] = useState(false); + + const saveTableName = cfg.saveConfig?.tableName ?? cfg.targetTable ?? ""; + + useEffect(() => { + fetchTableList().then(setTables); + }, []); + + const updateConfig = useCallback( + (partial: Partial) => { + onConfigChange({ ...cfg, ...partial }); + }, + [cfg, onConfigChange] + ); + + const updateSection = useCallback( + (sectionId: string, partial: Partial) => { + const sections = cfg.sections.map((s) => + s.id === sectionId ? { ...s, ...partial } : s + ); + updateConfig({ sections }); + }, + [cfg.sections, updateConfig] + ); + + const addSection = useCallback(() => { + const newId = `section_${Date.now()}`; + const newSection: PopFieldSection = { + id: newId, + style: "input", + columns: "auto", + showLabels: true, + fields: [], + }; + updateConfig({ sections: [...cfg.sections, newSection] }); + }, [cfg.sections, updateConfig]); + + const removeSection = useCallback( + (sectionId: string) => { + if (cfg.sections.length <= 1) return; + updateConfig({ sections: cfg.sections.filter((s) => s.id !== sectionId) }); + }, + [cfg.sections, updateConfig] + ); + + const moveSectionUp = useCallback( + (index: number) => { + if (index <= 0) return; + const sections = [...cfg.sections]; + [sections[index - 1], sections[index]] = [ + sections[index], + sections[index - 1], + ]; + updateConfig({ sections }); + }, + [cfg.sections, updateConfig] + ); + + const handleSaveTableChange = useCallback( + (tableName: string) => { + const next = tableName === saveTableName ? "" : tableName; + const saveConfig: PopFieldSaveConfig = { + ...cfg.saveConfig, + tableName: next, + fieldMappings: cfg.saveConfig?.fieldMappings ?? [], + }; + updateConfig({ saveConfig, targetTable: next }); + }, + [cfg.saveConfig, saveTableName, updateConfig] + ); + + const allFields = useMemo(() => { + return cfg.sections.flatMap((s) => + (s.fields ?? []).map((f) => ({ field: f, section: s })) + ); + }, [cfg.sections]); + + const displayFields = useMemo(() => { + return cfg.sections + .filter((s) => migrateStyle(s.style) === "display") + .flatMap((s) => (s.fields ?? []).map((f) => ({ field: f, section: s }))); + }, [cfg.sections]); + + const inputFields = useMemo(() => { + return cfg.sections + .filter((s) => migrateStyle(s.style) === "input") + .flatMap((s) => (s.fields ?? []).map((f) => ({ field: f, section: s }))); + }, [cfg.sections]); + + const hasDisplayFields = displayFields.length > 0; + const hasInputFields = inputFields.length > 0; + + return ( + + + + 레이아웃 + + + 저장 + + + + + {cfg.sections.map((section, idx) => ( + 1} + onUpdate={(partial) => updateSection(section.id, partial)} + onRemove={() => removeSection(section.id)} + onMoveUp={() => moveSectionUp(idx)} + /> + ))} + + + + + + + + ); +} + +// ======================================== +// SaveTabContent: 저장 탭 (필드 중심 순차 설정) +// 1. 테이블 설정 (읽기/저장 테이블 + PK) +// 2. 읽기 필드 매핑 (display 섹션) +// 3. 입력 필드 매핑 (input 섹션) +// ======================================== + +interface SaveTabContentProps { + cfg: PopFieldConfig; + tables: TableInfo[]; + saveTableName: string; + saveTableOpen: boolean; + setSaveTableOpen: (v: boolean) => void; + readTableOpen: boolean; + setReadTableOpen: (v: boolean) => void; + allFields: { field: PopFieldItem; section: PopFieldSection }[]; + displayFields: { field: PopFieldItem; section: PopFieldSection }[]; + inputFields: { field: PopFieldItem; section: PopFieldSection }[]; + hasDisplayFields: boolean; + hasInputFields: boolean; + onSaveTableChange: (tableName: string) => void; + onUpdateConfig: (partial: Partial) => void; +} + +function SaveTabContent({ + cfg, + tables, + saveTableName, + saveTableOpen, + setSaveTableOpen, + readTableOpen, + setReadTableOpen, + allFields, + displayFields, + inputFields, + hasDisplayFields, + hasInputFields, + onSaveTableChange, + onUpdateConfig, +}: SaveTabContentProps) { + const [saveColumns, setSaveColumns] = useState([]); + const [readColumns, setReadColumns] = useState([]); + const [jsonKeysMap, setJsonKeysMap] = useState>({}); + + useEffect(() => { + if (saveTableName) { + fetchTableColumns(saveTableName).then(setSaveColumns); + } else { + setSaveColumns([]); + } + }, [saveTableName]); + + const readTableName = cfg.readSource?.tableName ?? ""; + const readSameAsSave = readTableName === saveTableName && !!saveTableName; + const readTableForFetch = readSameAsSave ? saveTableName : readTableName; + + useEffect(() => { + if (readTableForFetch) { + fetchTableColumns(readTableForFetch).then(setReadColumns); + } else { + setReadColumns([]); + } + }, [readTableForFetch]); + + const fetchJsonKeysForColumn = useCallback( + async (tableName: string, columnName: string) => { + const cacheKey = `${tableName}__${columnName}`; + if (jsonKeysMap[cacheKey]) return; + try { + const result = await dataApi.getTableData(tableName, { page: 1, size: 1 }); + const row = result.data?.[0]; + if (!row || !row[columnName]) return; + const raw = row[columnName]; + const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + setJsonKeysMap((prev) => ({ ...prev, [cacheKey]: Object.keys(parsed).sort() })); + } + } catch { + // JSON 파싱 실패 시 무시 + } + }, + [jsonKeysMap] + ); + + const getJsonKeys = useCallback( + (tableName: string, columnName: string): string[] => { + return jsonKeysMap[`${tableName}__${columnName}`] ?? []; + }, + [jsonKeysMap] + ); + + // --- 저장 매핑 로직 --- + const saveMappings = cfg.saveConfig?.fieldMappings ?? []; + + const getSaveMappingForField = (fieldId: string): PopFieldSaveMapping => { + return ( + saveMappings.find((x) => x.fieldId === fieldId) ?? { + fieldId, + valueSource: "direct", + targetColumn: "", + } + ); + }; + + const syncAndUpdateSaveMappings = useCallback( + (updater?: (prev: PopFieldSaveMapping[]) => PopFieldSaveMapping[]) => { + const fieldIds = new Set(allFields.map(({ field }) => field.id)); + const prev = saveMappings.filter((m) => fieldIds.has(m.fieldId)); + const next = updater ? updater(prev) : prev; + const added = allFields.filter( + ({ field }) => !next.some((m) => m.fieldId === field.id) + ); + const merged: PopFieldSaveMapping[] = [ + ...next, + ...added.map(({ field }) => ({ + fieldId: field.id, + valueSource: "direct" as FieldValueSource, + targetColumn: "", + })), + ]; + const structureChanged = + merged.length !== saveMappings.length || + merged.some((m, i) => m.fieldId !== saveMappings[i]?.fieldId); + if (updater || structureChanged) { + onUpdateConfig({ + saveConfig: { + ...cfg.saveConfig, + tableName: saveTableName, + fieldMappings: merged, + }, + }); + } + }, + [allFields, saveMappings, cfg.saveConfig, saveTableName, onUpdateConfig] + ); + + const fieldIdsKey = allFields.map(({ field }) => field.id).join(","); + useEffect(() => { + syncAndUpdateSaveMappings(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fieldIdsKey]); + + const updateSaveMapping = useCallback( + (fieldId: string, partial: Partial) => { + syncAndUpdateSaveMappings((prev) => + prev.map((m) => (m.fieldId === fieldId ? { ...m, ...partial } : m)) + ); + }, + [syncAndUpdateSaveMappings] + ); + + // --- 숨은 필드 매핑 로직 --- + const hiddenMappings = cfg.saveConfig?.hiddenMappings ?? []; + + const addHiddenMapping = useCallback(() => { + const newMapping: PopFieldHiddenMapping = { + id: `hidden_${Date.now()}`, + valueSource: "db_column", + targetColumn: "", + label: "", + }; + onUpdateConfig({ + saveConfig: { + ...cfg.saveConfig, + tableName: saveTableName, + fieldMappings: cfg.saveConfig?.fieldMappings ?? [], + hiddenMappings: [...hiddenMappings, newMapping], + }, + }); + }, [cfg.saveConfig, saveTableName, hiddenMappings, onUpdateConfig]); + + const updateHiddenMapping = useCallback( + (id: string, partial: Partial) => { + onUpdateConfig({ + saveConfig: { + ...cfg.saveConfig, + tableName: saveTableName, + fieldMappings: cfg.saveConfig?.fieldMappings ?? [], + hiddenMappings: hiddenMappings.map((m) => + m.id === id ? { ...m, ...partial } : m + ), + }, + }); + }, + [cfg.saveConfig, saveTableName, hiddenMappings, onUpdateConfig] + ); + + const removeHiddenMapping = useCallback( + (id: string) => { + onUpdateConfig({ + saveConfig: { + ...cfg.saveConfig, + tableName: saveTableName, + fieldMappings: cfg.saveConfig?.fieldMappings ?? [], + hiddenMappings: hiddenMappings.filter((m) => m.id !== id), + }, + }); + }, + [cfg.saveConfig, saveTableName, hiddenMappings, onUpdateConfig] + ); + + // --- 레이아웃 auto 필드 감지 (입력 섹션에서 inputType=auto인 필드) --- + const autoInputFields = useMemo( + () => inputFields.filter(({ field }) => field.inputType === "auto"), + [inputFields] + ); + const regularInputFields = useMemo( + () => inputFields.filter(({ field }) => field.inputType !== "auto"), + [inputFields] + ); + + // --- 자동생성 필드 로직 --- + const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? []; + const [numberingRules, setNumberingRules] = useState<{ ruleId: string; ruleName: string }[]>([]); + + // 레이아웃 auto 필드 → autoGenMappings 자동 동기화 + const autoFieldIdsKey = autoInputFields.map(({ field }) => field.id).join(","); + useEffect(() => { + if (autoInputFields.length === 0) return; + const current = cfg.saveConfig?.autoGenMappings ?? []; + const linkedIds = new Set(current.filter((m) => m.linkedFieldId).map((m) => m.linkedFieldId)); + const toAdd: PopFieldAutoGenMapping[] = []; + for (const { field } of autoInputFields) { + if (!linkedIds.has(field.id)) { + toAdd.push({ + id: `autogen_linked_${field.id}`, + linkedFieldId: field.id, + label: field.labelText || "", + targetColumn: "", + numberingRuleId: field.autoNumber?.numberingRuleId ?? "", + showInForm: true, + showResultModal: true, + }); + } + } + if (toAdd.length > 0) { + onUpdateConfig({ + saveConfig: { + ...cfg.saveConfig, + tableName: saveTableName, + fieldMappings: cfg.saveConfig?.fieldMappings ?? [], + autoGenMappings: [...current, ...toAdd], + }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoFieldIdsKey]); + + useEffect(() => { + if (saveTableName) { + getAvailableNumberingRulesForScreen(saveTableName) + .then((res) => { + if (res.success && Array.isArray(res.data)) { + setNumberingRules( + res.data.map((r: any) => ({ + ruleId: String(r.ruleId ?? r.rule_id ?? ""), + ruleName: String(r.ruleName ?? r.rule_name ?? ""), + })) + ); + } + }) + .catch(() => setNumberingRules([])); + } + }, [saveTableName]); + + const addAutoGenMapping = useCallback(() => { + const newMapping: PopFieldAutoGenMapping = { + id: `autogen_${Date.now()}`, + label: "", + targetColumn: "", + numberingRuleId: "", + showInForm: false, + showResultModal: true, + }; + onUpdateConfig({ + saveConfig: { + ...cfg.saveConfig, + tableName: saveTableName, + fieldMappings: cfg.saveConfig?.fieldMappings ?? [], + autoGenMappings: [...autoGenMappings, newMapping], + }, + }); + }, [cfg.saveConfig, saveTableName, autoGenMappings, onUpdateConfig]); + + const updateAutoGenMapping = useCallback( + (id: string, partial: Partial) => { + onUpdateConfig({ + saveConfig: { + ...cfg.saveConfig, + tableName: saveTableName, + fieldMappings: cfg.saveConfig?.fieldMappings ?? [], + autoGenMappings: autoGenMappings.map((m) => + m.id === id ? { ...m, ...partial } : m + ), + }, + }); + }, + [cfg.saveConfig, saveTableName, autoGenMappings, onUpdateConfig] + ); + + const removeAutoGenMapping = useCallback( + (id: string) => { + onUpdateConfig({ + saveConfig: { + ...cfg.saveConfig, + tableName: saveTableName, + fieldMappings: cfg.saveConfig?.fieldMappings ?? [], + autoGenMappings: autoGenMappings.filter((m) => m.id !== id), + }, + }); + }, + [cfg.saveConfig, saveTableName, autoGenMappings, onUpdateConfig] + ); + + // --- 읽기 매핑 로직 --- + const getReadMappingForField = (fieldId: string): PopFieldReadMapping => { + return ( + cfg.readSource?.fieldMappings?.find((x) => x.fieldId === fieldId) ?? { + fieldId, + valueSource: "db_column", + columnName: "", + } + ); + }; + + const updateReadMapping = useCallback( + (fieldId: string, partial: Partial) => { + const prev = cfg.readSource?.fieldMappings ?? []; + const next = prev.map((m) => + m.fieldId === fieldId ? { ...m, ...partial } : m + ); + if (!next.some((m) => m.fieldId === fieldId)) { + next.push({ fieldId, valueSource: "db_column", columnName: "", ...partial }); + } + onUpdateConfig({ + readSource: { + ...cfg.readSource, + tableName: readTableName || saveTableName, + pkColumn: cfg.readSource?.pkColumn ?? "", + fieldMappings: next, + }, + }); + }, + [cfg.readSource, readTableName, saveTableName, onUpdateConfig] + ); + + // --- 읽기 테이블 변경 --- + const handleReadSameAsSaveChange = useCallback( + (checked: boolean) => { + onUpdateConfig({ + readSource: { + tableName: checked ? saveTableName : "", + pkColumn: checked ? (cfg.readSource?.pkColumn ?? "") : "", + fieldMappings: cfg.readSource?.fieldMappings ?? [], + }, + }); + }, + [saveTableName, cfg.readSource, onUpdateConfig] + ); + + const handleReadTableChange = useCallback( + (tableName: string) => { + onUpdateConfig({ + readSource: { + ...cfg.readSource, + tableName, + pkColumn: cfg.readSource?.pkColumn ?? "", + fieldMappings: cfg.readSource?.fieldMappings ?? [], + }, + }); + }, + [cfg.readSource, onUpdateConfig] + ); + + const colName = (c: ColumnInfo) => c.name; + + const noFields = allFields.length === 0; + + const [collapsed, setCollapsed] = useState>({}); + const toggleSection = useCallback((key: string) => { + setCollapsed((prev) => ({ ...prev, [key]: !prev[key] })); + }, []); + + return ( +
+ {noFields && ( +

+ 레이아웃 탭에서 섹션과 필드를 먼저 추가해주세요. +

+ )} + + {/* ── 1. 테이블 설정 ── */} + {!noFields && ( +
+
toggleSection("table")} + > + {collapsed["table"] ? ( + + ) : ( + + )} + 테이블 설정 +
+ {!collapsed["table"] &&
+ {/* 읽기 테이블 (display 섹션이 있을 때만) */} + {hasDisplayFields && ( + <> +
+ +
+
+ + + 저장과 동일 + +
+
+ {!readSameAsSave && ( + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((t) => ( + { + handleReadTableChange(v); + setReadTableOpen(false); + }} + className="text-xs" + > + + {t.tableName} + + ))} + + + + + + )} +
+
+ + +
+ + )} + + {/* 저장 테이블 */} + {hasInputFields && ( +
+ + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((t) => ( + { + onSaveTableChange(v === saveTableName ? "" : v); + setSaveTableOpen(false); + }} + className="text-xs" + > + + {t.tableName} + {t.displayName && t.displayName !== t.tableName && ( + + ({t.displayName}) + + )} + + ))} + + + + + +
+ )} +
} +
+ )} + + {/* ── 2. 읽기 필드 매핑 (display) ── */} + {hasDisplayFields && (readTableForFetch || readSameAsSave) && ( +
+
toggleSection("read")} + > + {collapsed["read"] ? ( + + ) : ( + + )} + 읽기 필드 + + (읽기 폼) + +
+ {!collapsed["read"] &&
+ {readColumns.length === 0 ? ( +

+ 읽기 테이블의 컬럼을 불러오는 중... +

+ ) : ( + displayFields.map(({ field }) => { + const m = getReadMappingForField(field.id); + const sm = getSaveMappingForField(field.id); + const isJson = m.valueSource === "json_extract"; + return ( +
+
+ + {field.labelText || "(미설정)"} + + + {!isJson && ( + <> + + + + )} +
+ {isJson && ( +
+ + + . + updateReadMapping(field.id, { jsonKey: v })} + onOpen={() => { + if (readTableForFetch && m.columnName) { + fetchJsonKeysForColumn(readTableForFetch, m.columnName); + } + }} + /> +
+ )} + {saveTableName && saveColumns.length > 0 && ( +
+ + +
+ )} +
+ ); + }) + )} +
} +
+ )} + + {/* ── 3. 입력 필드 매핑 (input, auto 타입 제외) ── */} + {regularInputFields.length > 0 && saveTableName && ( +
+
toggleSection("input")} + > + {collapsed["input"] ? ( + + ) : ( + + )} + 입력 필드 + + (입력 폼 → 저장) + +
+ {!collapsed["input"] &&
+ {saveColumns.length === 0 ? ( +

+ 저장 테이블의 컬럼을 불러오는 중... +

+ ) : ( + regularInputFields.map(({ field }) => { + const m = getSaveMappingForField(field.id); + return ( +
+ + {field.labelText || "(미설정)"} + + + +
+ ); + }) + )} +
} +
+ )} + + {/* ── 4. 숨은 필드 매핑 (읽기 필드와 동일한 소스 구조) ── */} + {saveTableName && ( +
+
toggleSection("hidden")} + > + {collapsed["hidden"] ? ( + + ) : ( + + )} + 숨은 필드 + + (UI 미표시, 전달 데이터에서 추출하여 저장) + +
+ {!collapsed["hidden"] &&
+ {hiddenMappings.map((m) => { + const isJson = m.valueSource === "json_extract"; + return ( +
+
+ updateHiddenMapping(m.id, { label: e.target.value })} + placeholder="라벨 (관리용)" + className="h-7 flex-1 text-xs" + /> + +
+
+ + {!isJson && ( + <> + + + )} +
+ {isJson && ( +
+ + . + updateHiddenMapping(m.id, { sourceJsonKey: v })} + onOpen={() => { + if (readTableForFetch && m.sourceJsonColumn) { + fetchJsonKeysForColumn(readTableForFetch, m.sourceJsonColumn); + } + }} + /> +
+ )} +
+ + +
+
+ ); + })} + +
} +
+ )} + + {/* ── 5. 자동생성 필드 ── */} + {saveTableName && ( +
+
toggleSection("autogen")} + > + {collapsed["autogen"] ? ( + + ) : ( + + )} + 자동생성 필드 + + (저장 시 서버에서 채번) + +
+ {!collapsed["autogen"] &&
+ {autoGenMappings.map((m) => { + const isLinked = !!m.linkedFieldId; + return ( +
+
+ {isLinked && ( + + 레이아웃 + + )} + updateAutoGenMapping(m.id, { label: e.target.value })} + placeholder="라벨 (예: 입고번호)" + className="h-7 flex-1 text-xs" + readOnly={isLinked} + /> + {!isLinked && ( + + )} +
+
+ + +
+
+ + +
+
+ {!isLinked && ( +
+ updateAutoGenMapping(m.id, { showInForm: v })} + /> + +
+ )} +
+ updateAutoGenMapping(m.id, { showResultModal: v })} + /> + +
+
+
+ ); + })} + +
} +
+ )} + + {/* 저장 테이블 미선택 안내 */} + {(regularInputFields.length > 0 || autoInputFields.length > 0) && !saveTableName && !noFields && ( +

+ 저장 테이블을 선택하면 입력 필드 매핑이 표시됩니다. +

+ )} +
+ ); +} + +// ======================================== +// SectionEditor: 섹션 단위 편집 +// ======================================== + +interface SectionEditorProps { + section: PopFieldSection; + index: number; + canDelete: boolean; + onUpdate: (partial: Partial) => void; + onRemove: () => void; + onMoveUp: () => void; +} + +function migrateStyle(style: string): FieldSectionStyle { + if (style === "display" || style === "input") return style; + if (style === "summary") return "display"; + if (style === "form") return "input"; + return "input"; +} + +function SectionEditor({ + section, + index, + canDelete, + onUpdate, + onRemove, + onMoveUp, +}: SectionEditorProps) { + const [collapsed, setCollapsed] = useState(false); + const resolvedStyle = migrateStyle(section.style); + + const sectionFields = section.fields || []; + + const updateField = useCallback( + (fieldId: string, partial: Partial) => { + const fields = sectionFields.map((f) => + f.id === fieldId ? { ...f, ...partial } : f + ); + onUpdate({ fields }); + }, + [sectionFields, onUpdate] + ); + + const addField = useCallback(() => { + const fieldId = `field_${Date.now()}`; + const newField: PopFieldItem = { + id: fieldId, + inputType: "text", + fieldName: fieldId, + labelText: "", + readOnly: false, + }; + onUpdate({ fields: [...sectionFields, newField] }); + }, [sectionFields, onUpdate]); + + const removeField = useCallback( + (fieldId: string) => { + onUpdate({ fields: sectionFields.filter((f) => f.id !== fieldId) }); + }, + [sectionFields, onUpdate] + ); + + return ( +
+ {/* 섹션 헤더 */} +
setCollapsed(!collapsed)} + > + {collapsed ? ( + + ) : ( + + )} + + 섹션 {index + 1} + {section.label && ` - ${section.label}`} + + {index > 0 && ( + + )} + {canDelete && ( + + )} +
+ + {!collapsed && ( +
+ {/* 섹션 라벨 */} +
+ + onUpdate({ label: e.target.value })} + placeholder="선택사항" + className="mt-1 h-7 text-xs" + /> +
+ + {/* 스타일 + 열 수 (가로 배치) */} +
+
+ + +
+
+ + +
+
+ + {/* 라벨 표시 토글 */} +
+ + onUpdate({ showLabels: v })} + /> +
+ + {/* 커스텀 색상 */} + onUpdate({ appearance })} + /> + + {/* 필드 목록 */} +
+ + {sectionFields.map((field) => ( + updateField(field.id, partial)} + onRemove={() => removeField(field.id)} + /> + ))} + +
+
+ )} +
+ ); +} + +// ======================================== +// FieldItemEditor: 필드 단위 편집 +// ======================================== + +interface FieldItemEditorProps { + field: PopFieldItem; + sectionStyle?: FieldSectionStyle; + onUpdate: (partial: Partial) => void; + onRemove: () => void; +} + +function FieldItemEditor({ + field, + sectionStyle, + onUpdate, + onRemove, +}: FieldItemEditorProps) { + const isDisplay = sectionStyle === "display"; + const [expanded, setExpanded] = useState(false); + + return ( +
+ {/* 필드 헤더 */} +
setExpanded(!expanded)} + > + {expanded ? ( + + ) : ( + + )} + + {field.labelText || "(미설정)"} + + [{FIELD_INPUT_TYPE_LABELS[field.inputType]}] + + {field.readOnly && ( + (읽기전용) + )} + + +
+ + {expanded && ( +
+ {/* 라벨 + 타입 */} +
+
+ + onUpdate({ labelText: e.target.value })} + placeholder="표시 라벨" + className="mt-0.5 h-7 text-xs" + /> +
+
+ + +
+
+ + {/* 플레이스홀더 */} +
+ + onUpdate({ placeholder: e.target.value })} + placeholder="힌트 텍스트" + className="mt-0.5 h-7 text-xs" + /> +
+ + {/* 읽기전용 + 필수 (입력 폼에서만 표시) */} + {!isDisplay && ( +
+
+ onUpdate({ readOnly: v })} + /> + +
+
+ + onUpdate({ + validation: { ...field.validation, required: v }, + }) + } + /> + +
+
+ )} + + {/* 단위 (number, numpad) */} + {(field.inputType === "number" || field.inputType === "numpad") && ( +
+ + onUpdate({ unit: e.target.value })} + placeholder="EA, KG 등" + className="mt-0.5 h-7 text-xs" + /> +
+ )} + + {/* select 전용: 옵션 소스 */} + {field.inputType === "select" && ( + onUpdate({ selectSource: source })} + /> + )} + + {/* auto 전용: 채번 설정 */} + {field.inputType === "auto" && ( + onUpdate({ autoNumber })} + /> + )} +
+ )} +
+ ); +} + +// ======================================== +// SelectSourceEditor: select 옵션 소스 편집 +// ======================================== + +function SelectSourceEditor({ + source, + onUpdate, +}: { + source?: FieldSelectSource; + onUpdate: (source: FieldSelectSource) => void; +}) { + const current: FieldSelectSource = source || { + type: "static", + staticOptions: [], + }; + + return ( +
+ + + + + {current.type === "static" && ( + onUpdate({ ...current, staticOptions: opts })} + /> + )} + + {current.type === "table" && ( + onUpdate({ ...current, ...partial })} + /> + )} +
+ ); +} + +// ======================================== +// StaticOptionsEditor: 정적 옵션 CRUD +// ======================================== + +function StaticOptionsEditor({ + options, + onUpdate, +}: { + options: { value: string; label: string }[]; + onUpdate: (options: { value: string; label: string }[]) => void; +}) { + return ( +
+ {options.map((opt, idx) => ( +
+ { + const next = [...options]; + next[idx] = { ...opt, value: e.target.value }; + onUpdate(next); + }} + placeholder="값" + className="h-6 flex-1 text-[10px]" + /> + { + const next = [...options]; + next[idx] = { ...opt, label: e.target.value }; + onUpdate(next); + }} + placeholder="표시" + className="h-6 flex-1 text-[10px]" + /> + +
+ ))} + +
+ ); +} + +// ======================================== +// TableSourceEditor: 테이블 소스 설정 +// ======================================== + +function TableSourceEditor({ + source, + onUpdate, +}: { + source: FieldSelectSource; + onUpdate: (partial: Partial) => void; +}) { + const [tables, setTables] = useState([]); + const [columns, setColumns] = useState([]); + const [tblOpen, setTblOpen] = useState(false); + + useEffect(() => { + fetchTableList().then(setTables); + }, []); + + useEffect(() => { + if (source.tableName) { + fetchTableColumns(source.tableName).then(setColumns); + } else { + setColumns([]); + } + }, [source.tableName]); + + return ( +
+ {/* 테이블 Combobox */} + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((t) => ( + { + onUpdate({ tableName: v }); + setTblOpen(false); + }} + className="text-xs" + > + + {t.tableName} + + ))} + + + + + + + {/* 값 컬럼 / 라벨 컬럼 */} +
+
+ + +
+
+ + +
+
+
+ ); +} + +// ======================================== +// AutoNumberEditor: 자동 채번 설정 +// ======================================== + +function AutoNumberEditor({ + config, + onUpdate, +}: { + config?: AutoNumberConfig; + onUpdate: (config: AutoNumberConfig) => void; +}) { + const current: AutoNumberConfig = config || { + prefix: "", + dateFormat: "YYYYMMDD", + separator: "-", + sequenceDigits: 3, + }; + + return ( +
+ + +
+
+ + onUpdate({ ...current, prefix: e.target.value })} + placeholder="IN-" + className="mt-0.5 h-7 text-xs" + /> +
+
+ + +
+
+ +
+
+ + onUpdate({ ...current, separator: e.target.value })} + placeholder="-" + className="mt-0.5 h-7 text-xs" + /> +
+
+ + + onUpdate({ + ...current, + sequenceDigits: Number(e.target.value) || 3, + }) + } + min={1} + max={10} + className="mt-0.5 h-7 text-xs" + /> +
+
+ + {/* 미리보기 */} +
+ 미리보기:{" "} + + {current.prefix || ""} + {current.separator || ""} + {current.dateFormat === "YYMM" + ? "2602" + : current.dateFormat === "YYMMDD" + ? "260226" + : "20260226"} + {current.separator || ""} + {"0".repeat(current.sequenceDigits || 3).slice(0, -1)}1 + +
+
+ ); +} + +// ======================================== +// JsonKeySelect: JSON 키 드롭다운 (자동 추출) +// ======================================== + +function JsonKeySelect({ + value, + keys, + onValueChange, + onOpen, +}: { + value: string; + keys: string[]; + onValueChange: (v: string) => void; + onOpen?: () => void; +}) { + const [open, setOpen] = useState(false); + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (nextOpen) onOpen?.(); + }; + + if (keys.length === 0 && !value) { + return ( + onValueChange(e.target.value)} + onFocus={() => onOpen?.()} + className="h-7 w-24 text-xs" + /> + ); + } + + return ( + + + + + + + + + + {keys.length === 0 ? "데이터를 불러오는 중..." : "일치하는 키가 없습니다."} + + + {keys.map((k) => ( + { + onValueChange(v === value ? "" : v); + setOpen(false); + }} + className="text-xs" + > + + {k} + + ))} + + + + + + ); +} + +// ======================================== +// AppearanceEditor: 섹션 외관 설정 +// ======================================== + +const BG_COLOR_OPTIONS = [ + { value: "__default__", label: "기본" }, + { value: "bg-emerald-50", label: "초록" }, + { value: "bg-blue-50", label: "파랑" }, + { value: "bg-amber-50", label: "노랑" }, + { value: "bg-rose-50", label: "빨강" }, + { value: "bg-purple-50", label: "보라" }, + { value: "bg-gray-50", label: "회색" }, +] as const; + +const BORDER_COLOR_OPTIONS = [ + { value: "__default__", label: "기본" }, + { value: "border-emerald-200", label: "초록" }, + { value: "border-blue-200", label: "파랑" }, + { value: "border-amber-200", label: "노랑" }, + { value: "border-rose-200", label: "빨강" }, + { value: "border-purple-200", label: "보라" }, + { value: "border-gray-200", label: "회색" }, +] as const; + +function AppearanceEditor({ + style, + appearance, + onUpdate, +}: { + style: FieldSectionStyle; + appearance?: FieldSectionAppearance; + onUpdate: (appearance: FieldSectionAppearance) => void; +}) { + const defaults = DEFAULT_SECTION_APPEARANCES[style]; + const current = appearance || {}; + + return ( +
+ +
+
+ + +
+
+ + +
+
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-field/index.tsx b/frontend/lib/registry/pop-components/pop-field/index.tsx new file mode 100644 index 00000000..1c436301 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-field/index.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { PopComponentRegistry } from "../../PopComponentRegistry"; +import { PopFieldComponent } from "./PopFieldComponent"; +import { PopFieldConfigPanel } from "./PopFieldConfig"; +import type { PopFieldConfig } from "./types"; +import { DEFAULT_FIELD_CONFIG, FIELD_INPUT_TYPE_LABELS } from "./types"; + +function PopFieldPreviewComponent({ + config, + label, +}: { + config?: PopFieldConfig; + label?: string; +}) { + const cfg: PopFieldConfig = { + ...DEFAULT_FIELD_CONFIG, + ...config, + sections: config?.sections?.length ? config.sections : DEFAULT_FIELD_CONFIG.sections, + }; + const totalFields = cfg.sections.reduce( + (sum, s) => sum + (s.fields?.length || 0), + 0 + ); + const sectionCount = cfg.sections.length; + + return ( +
+ + {label || "입력 필드"} + +
+ {cfg.sections.map((section) => + (section.fields || []).slice(0, 3).map((field) => ( +
+ + {field.labelText || field.fieldName || FIELD_INPUT_TYPE_LABELS[field.inputType]} + +
+ )) + )} +
+ + {sectionCount}섹션 / {totalFields}필드 + +
+ ); +} + +PopComponentRegistry.registerComponent({ + id: "pop-field", + name: "입력 필드", + description: "저장용 값 입력 (섹션별 멀티필드, 읽기전용/입력 혼합)", + category: "input", + icon: "TextCursorInput", + component: PopFieldComponent, + configPanel: PopFieldConfigPanel, + preview: PopFieldPreviewComponent, + defaultProps: DEFAULT_FIELD_CONFIG, + connectionMeta: { + sendable: [ + { + key: "value_changed", + label: "값 변경", + type: "value", + description: "필드값 변경 시 fieldName + value + allValues 전달", + }, + ], + receivable: [ + { + key: "set_value", + label: "값 설정", + type: "value", + description: "외부에서 특정 필드 또는 일괄로 값 세팅", + }, + ], + }, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-field/types.ts b/frontend/lib/registry/pop-components/pop-field/types.ts new file mode 100644 index 00000000..6d9e1734 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-field/types.ts @@ -0,0 +1,210 @@ +/** + * pop-field 타입 정의 + * 섹션 기반 멀티필드: 하나의 컴포넌트 안에 여러 섹션, 각 섹션에 여러 필드 + * 저장용 값 입력 (text/number/date/select/auto/numpad) + */ + +import type { DataSourceFilter } from "../types"; + +// ===== 서브타입 ===== + +export type FieldInputType = "text" | "number" | "date" | "select" | "auto" | "numpad"; + +export const FIELD_INPUT_TYPE_LABELS: Record = { + text: "텍스트", + number: "숫자", + date: "날짜", + select: "선택", + auto: "자동채번", + numpad: "숫자패드", +}; + +// ===== 섹션 스타일 ===== + +export type FieldSectionStyle = "display" | "input"; + +export const FIELD_SECTION_STYLE_LABELS: Record = { + display: "읽기 폼", + input: "입력 폼", +}; + +// 섹션 커스텀 외관 옵션 +export interface FieldSectionAppearance { + bgColor?: string; + borderColor?: string; + textColor?: string; +} + +export const DEFAULT_SECTION_APPEARANCES: Record = { + display: { bgColor: "bg-emerald-50", borderColor: "border-emerald-200", textColor: "text-foreground" }, + input: { bgColor: "bg-background", borderColor: "border-border", textColor: "text-foreground" }, +}; + +// ===== select 옵션 소스 ===== + +export type FieldSelectSourceType = "static" | "table"; + +export interface FieldSelectSource { + type: FieldSelectSourceType; + staticOptions?: { value: string; label: string }[]; + tableName?: string; + valueColumn?: string; + labelColumn?: string; + filters?: DataSourceFilter[]; +} + +// ===== 자동 채번 설정 ===== + +export interface AutoNumberConfig { + prefix?: string; + dateFormat?: string; + separator?: string; + sequenceDigits?: number; + numberingRuleId?: string; +} + +// ===== 유효성 검증 ===== + +export interface PopFieldValidation { + required?: boolean; + min?: number; + max?: number; + pattern?: string; + customMessage?: string; +} + +// ===== 개별 필드 정의 ===== + +export interface PopFieldItem { + id: string; + inputType: FieldInputType; + fieldName: string; + labelText?: string; + placeholder?: string; + defaultValue?: unknown; + readOnly?: boolean; + unit?: string; + + selectSource?: FieldSelectSource; + autoNumber?: AutoNumberConfig; + validation?: PopFieldValidation; +} + +// ===== 섹션 정의 ===== + +export interface PopFieldSection { + id: string; + label?: string; + style: FieldSectionStyle; + columns: "auto" | 1 | 2 | 3 | 4; + showLabels: boolean; + appearance?: FieldSectionAppearance; + fields: PopFieldItem[]; +} + +// ===== 저장 설정: 값 소스 타입 ===== + +export type FieldValueSource = "direct" | "json_extract" | "db_column"; + +export const FIELD_VALUE_SOURCE_LABELS: Record = { + direct: "직접 입력", + json_extract: "JSON 추출", + db_column: "DB 컬럼", +}; + +// ===== 저장 설정: 필드-컬럼 매핑 ===== + +export interface PopFieldSaveMapping { + fieldId: string; + valueSource: FieldValueSource; + targetColumn: string; + sourceJsonColumn?: string; + sourceJsonKey?: string; +} + +// ===== 숨은 필드 매핑 (UI에 미표시, 전달 데이터에서 추출하여 저장) ===== + +export type HiddenValueSource = "json_extract" | "db_column"; + +export interface PopFieldHiddenMapping { + id: string; + label?: string; + valueSource: HiddenValueSource; + sourceJsonColumn?: string; + sourceJsonKey?: string; + sourceDbColumn?: string; + targetColumn: string; +} + +// ===== 자동생성 필드 (서버 채번규칙으로 저장 시점 생성) ===== + +export interface PopFieldAutoGenMapping { + id: string; + linkedFieldId?: string; + label: string; + targetColumn: string; + numberingRuleId?: string; + showInForm: boolean; + showResultModal: boolean; +} + +export interface PopFieldSaveConfig { + tableName: string; + fieldMappings: PopFieldSaveMapping[]; + hiddenMappings?: PopFieldHiddenMapping[]; + autoGenMappings?: PopFieldAutoGenMapping[]; +} + +// ===== 읽기 데이터 소스 ===== + +export interface PopFieldReadMapping { + fieldId: string; + valueSource: FieldValueSource; + columnName: string; + jsonKey?: string; +} + +export interface PopFieldReadSource { + tableName: string; + pkColumn: string; + fieldMappings: PopFieldReadMapping[]; +} + +// ===== pop-field 전체 설정 (루트) ===== + +export interface PopFieldConfig { + targetTable?: string; + sections: PopFieldSection[]; + saveConfig?: PopFieldSaveConfig; + readSource?: PopFieldReadSource; +} + +// ===== 기본값 ===== + +export const DEFAULT_FIELD_CONFIG: PopFieldConfig = { + targetTable: "", + sections: [ + { + id: "section_display", + label: "요약", + style: "display", + columns: "auto", + showLabels: true, + fields: [ + { id: "f_disp_1", inputType: "text", fieldName: "", labelText: "항목1", readOnly: true }, + { id: "f_disp_2", inputType: "text", fieldName: "", labelText: "항목2", readOnly: true }, + ], + }, + { + id: "section_input", + label: "입력", + style: "input", + columns: "auto", + showLabels: true, + fields: [ + { id: "f_input_1", inputType: "text", fieldName: "", labelText: "필드1" }, + { id: "f_input_2", inputType: "number", fieldName: "", labelText: "필드2", unit: "EA" }, + ], + }, + ], +};