From 7bf20bda14b97aa43bef01ccb9498f021312818b Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 27 Feb 2026 12:48:33 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat(pop-field):=20=EC=84=B9=EC=85=98=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=A9=80=ED=8B=B0=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pop-field 컴포넌트 전체 구현 (types, component, config, registry): - 6개 서브타입 (text/number/date/select/auto/numpad) - 섹션 기반 레이아웃 (summary/form, auto 열 수 계산) - 저장 탭 5섹션 (테이블/읽기/입력/숨은/자동생성 필드) - 자동생성 레이아웃 연결 (linkedFieldId) - 숨은 필드 DB/JSON 소스 선택 (JsonKeySelect 재사용) - 채번 규칙 API 연동 (getAvailableNumberingRulesForScreen) - 저장 탭 섹션별 접기/펼치기 토글 - 팔레트 4곳 등록 + index.ts 활성화 --- .gitignore | 12 +- .../pop/designer/panels/ComponentPalette.tsx | 8 +- .../pop/designer/renderers/PopRenderer.tsx | 1 + .../pop/designer/types/pop-layout.ts | 3 +- frontend/lib/registry/pop-components/index.ts | 3 +- .../pop-field/PopFieldComponent.tsx | 732 ++++++ .../pop-field/PopFieldConfig.tsx | 2220 +++++++++++++++++ .../pop-components/pop-field/index.tsx | 83 + .../pop-components/pop-field/types.ts | 210 ++ 9 files changed, 3268 insertions(+), 4 deletions(-) create mode 100644 frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx create mode 100644 frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx create mode 100644 frontend/lib/registry/pop-components/pop-field/index.tsx create mode 100644 frontend/lib/registry/pop-components/pop-field/types.ts 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" }, + ], + }, + ], +}; From aa319a6bda9321f6fdeb3eaacb87bc349d34f217 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 27 Feb 2026 14:57:24 +0900 Subject: [PATCH 02/13] =?UTF-8?q?feat(pop-card-list):=20=EC=9E=A5=EB=B0=94?= =?UTF-8?q?=EA=B5=AC=EB=8B=88=20=EB=AA=A9=EB=A1=9D=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?(cartListMode)=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CartListModeConfig 타입 추가 (sourceScreenId, cartType, statusFilter) - 원본 화면 카드 설정 자동 상속 (screenApi.getLayoutPop) - cart_items 조회 + row_data JSON 파싱 + __cart_ 접두사 병합 - 체크박스 선택 (전체/개별) + selected_items 이벤트 발행 - 인라인 삭제 (확인 후 즉시 DB 반영) - 수량 수정 (NumberInputModal 재사용, 로컬 __cart_modified) - 설정 패널: 장바구니 모드 토글 + 원본 화면/컴포넌트 선택 - connectionMeta: selected_items, confirm_trigger 추가 --- .../pop-card-list/PopCardListComponent.tsx | 300 ++++++++++++++++-- .../pop-card-list/PopCardListConfig.tsx | 207 +++++++++--- .../pop-components/pop-card-list/index.tsx | 2 + frontend/lib/registry/pop-components/types.ts | 12 +- 4 files changed, 455 insertions(+), 66 deletions(-) diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx index e3e7dc4c..0a7f4f9e 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx @@ -14,6 +14,7 @@ import { useRouter } from "next/navigation"; import { Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star, + Trash2, type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; @@ -28,12 +29,14 @@ import type { CardPresetSpec, CartItem, PackageEntry, + CartListModeConfig, } from "../types"; import { DEFAULT_CARD_IMAGE, CARD_PRESET_SPECS, } from "../types"; import { dataApi } from "@/lib/api/data"; +import { screenApi } from "@/lib/api/screen"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { useCartSync } from "@/hooks/pop/useCartSync"; import { NumberInputModal } from "./NumberInputModal"; @@ -121,6 +124,28 @@ function MarqueeText({ ); } +// cart_items 행의 row_data JSON을 풀어서 __cart_ 접두사 메타데이터와 병합 +function parseCartRow(dbRow: Record): Record { + let rowData: Record = {}; + try { + const raw = dbRow.row_data; + if (typeof raw === "string" && raw.trim()) rowData = JSON.parse(raw); + else if (typeof raw === "object" && raw !== null) rowData = raw as Record; + } catch { rowData = {}; } + + return { + ...rowData, + __cart_id: dbRow.id, + __cart_quantity: Number(dbRow.quantity) || 0, + __cart_package_unit: dbRow.package_unit || "", + __cart_package_entries: dbRow.package_entries, + __cart_status: dbRow.status || "in_cart", + __cart_memo: dbRow.memo || "", + __cart_row_key: dbRow.row_key || "", + __cart_modified: false, + }; +} + interface PopCardListComponentProps { config?: PopCardListConfig; className?: string; @@ -172,6 +197,14 @@ export function PopCardListComponent({ const cartType = config?.cartAction?.cartType; const cart = useCartSync(screenId || "", sourceTableName, cartType); + // 장바구니 목록 모드 플래그 및 상태 + const isCartListMode = config?.cartListMode?.enabled === true; + const [inheritedTemplate, setInheritedTemplate] = useState(null); + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + + // 장바구니 목록 모드에서는 원본 화면의 템플릿을 사용, 폴백으로 자체 설정 + const effectiveTemplate = (isCartListMode && inheritedTemplate) ? inheritedTemplate : template; + // 데이터 상태 const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); @@ -219,9 +252,9 @@ export function PopCardListComponent({ const cartRef = useRef(cart); cartRef.current = cart; - // "저장 요청" 이벤트 수신: 버튼 컴포넌트가 장바구니 저장을 요청하면 saveToDb 실행 + // "저장 요청" 이벤트 수신: 버튼 컴포넌트가 장바구니 저장을 요청하면 saveToDb 실행 (장바구니 목록 모드 제외) useEffect(() => { - if (!componentId) return; + if (!componentId || isCartListMode) return; const unsub = subscribe( `__comp_input__${componentId}__cart_save_trigger`, async (payload: unknown) => { @@ -233,16 +266,16 @@ export function PopCardListComponent({ } ); return unsub; - }, [componentId, subscribe, publish]); + }, [componentId, subscribe, publish, isCartListMode]); - // DB 로드 완료 후 초기 장바구니 상태를 버튼에 전달 + // DB 로드 완료 후 초기 장바구니 상태를 버튼에 전달 (장바구니 목록 모드 제외) useEffect(() => { - if (!componentId || cart.loading) return; + if (!componentId || cart.loading || isCartListMode) return; publish(`__comp_output__${componentId}__cart_updated`, { count: cart.cartCount, isDirty: cart.isDirty, }); - }, [componentId, cart.loading, cart.cartCount, cart.isDirty, publish]); + }, [componentId, cart.loading, cart.cartCount, cart.isDirty, publish, isCartListMode]); // 카드 선택 시 selected_row 이벤트 발행 const handleCardSelect = useCallback((row: RowData) => { @@ -454,7 +487,70 @@ export function PopCardListComponent({ [dataSource] ); + // 장바구니 목록 모드 설정을 직렬화 (의존성 안정화) + const cartListModeKey = useMemo( + () => JSON.stringify(config?.cartListMode || null), + [config?.cartListMode] + ); + useEffect(() => { + // 장바구니 목록 모드: cart_items에서 직접 조회 + if (isCartListMode) { + const cartListMode = config!.cartListMode!; + + // 설정 미완료 시 데이터 조회하지 않음 + if (!cartListMode.sourceScreenId || !cartListMode.cartType) { + setLoading(false); + setRows([]); + return; + } + + const fetchCartData = async () => { + setLoading(true); + setError(null); + try { + // 원본 화면 레이아웃에서 cardTemplate 상속 + if (cartListMode.sourceScreenId) { + try { + const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId); + const components = layoutJson?.components || []; + const matched = components.find( + (c: any) => + c.type === "pop-card-list" && + c.props?.cartAction?.cartType === cartListMode.cartType + ); + if (matched?.props?.cardTemplate) { + setInheritedTemplate(matched.props.cardTemplate); + } + } catch { + // 레이아웃 로드 실패 시 config.cardTemplate 폴백 + } + } + + // cart_items 조회 + const result = await dataApi.getTableData("cart_items", { + size: 500, + filters: { + cart_type: cartListMode.cartType, + status: cartListMode.statusFilter || "in_cart", + }, + }); + + const parsed = (result.data || []).map(parseCartRow); + setRows(parsed); + } catch (err) { + const message = err instanceof Error ? err.message : "장바구니 데이터 조회 실패"; + setError(message); + setRows([]); + } finally { + setLoading(false); + } + }; + fetchCartData(); + return; + } + + // 기본 모드: 데이터 소스에서 조회 if (!dataSource?.tableName) { setLoading(false); setRows([]); @@ -510,16 +606,51 @@ export function PopCardListComponent({ }; fetchData(); - }, [dataSourceKey]); // eslint-disable-line react-hooks/exhaustive-deps + }, [dataSourceKey, isCartListMode, cartListModeKey]); // eslint-disable-line react-hooks/exhaustive-deps // 이미지 URL 없는 항목 카운트 (내부 추적용, toast 미표시) useEffect(() => { - if (!loading && rows.length > 0 && template?.image?.enabled && template?.image?.imageColumn) { - const imageColumn = template.image.imageColumn; + if (!loading && rows.length > 0 && effectiveTemplate?.image?.enabled && effectiveTemplate?.image?.imageColumn) { + const imageColumn = effectiveTemplate.image.imageColumn; missingImageCountRef.current = rows.filter((row) => !row[imageColumn]).length; } - }, [loading, rows, template?.image]); + }, [loading, rows, effectiveTemplate?.image]); + // 장바구니 목록 모드: 항목 삭제 콜백 + const handleDeleteItem = useCallback((cartId: string) => { + setRows(prev => prev.filter(r => String(r.__cart_id) !== cartId)); + setSelectedKeys(prev => { + const next = new Set(prev); + next.delete(cartId); + return next; + }); + }, []); + + // 장바구니 목록 모드: 수량 수정 콜백 (로컬만 업데이트, DB 미반영) + const handleUpdateQuantity = useCallback(( + cartId: string, + quantity: number, + unit?: string, + entries?: PackageEntry[], + ) => { + setRows(prev => prev.map(r => { + if (String(r.__cart_id) !== cartId) return r; + return { + ...r, + __cart_quantity: quantity, + __cart_package_unit: unit || r.__cart_package_unit, + __cart_package_entries: entries || r.__cart_package_entries, + __cart_modified: true, + }; + })); + }, []); + + // 장바구니 목록 모드: 선택 항목 이벤트 발행 + useEffect(() => { + if (!componentId || !isCartListMode) return; + const selectedItems = filteredRows.filter(r => selectedKeys.has(String(r.__cart_id))); + publish(`__comp_output__${componentId}__selected_items`, selectedItems); + }, [selectedKeys, filteredRows, componentId, isCartListMode, publish]); // 카드 영역 스타일 const cardAreaStyle: React.CSSProperties = { @@ -549,7 +680,13 @@ export function PopCardListComponent({ ref={containerRef} className={`flex h-full w-full flex-col ${className || ""}`} > - {!dataSource?.tableName ? ( + {isCartListMode && (!config?.cartListMode?.sourceScreenId || !config?.cartListMode?.cartType) ? ( +
+

+ 원본 화면과 장바구니 구분값을 설정해주세요. +

+
+ ) : !isCartListMode && !dataSource?.tableName ? (

데이터 소스를 설정해주세요. @@ -569,6 +706,27 @@ export function PopCardListComponent({

) : ( <> + {/* 장바구니 목록 모드: 선택 바 */} + {isCartListMode && ( +
+ 0} + onChange={(e) => { + if (e.target.checked) { + setSelectedKeys(new Set(displayCards.map(r => String(r.__cart_id)))); + } else { + setSelectedKeys(new Set()); + } + }} + className="h-4 w-4 rounded border-gray-300" + /> + + {selectedKeys.size > 0 ? `${selectedKeys.size}개 선택됨` : "전체 선택"} + +
+ )} + {/* 카드 영역 (스크롤 가능) */}
{displayCards.map((row, index) => { - const codeValue = template?.header?.codeField && row[template.header.codeField] - ? String(row[template.header.codeField]) + const codeValue = effectiveTemplate?.header?.codeField && row[effectiveTemplate.header.codeField] + ? String(row[effectiveTemplate.header.codeField]) : null; const rowKey = codeValue ? `${codeValue}-${index}` : `card-${index}`; return ( { + const cartId = String(row.__cart_id); + setSelectedKeys(prev => { + const next = new Set(prev); + if (next.has(cartId)) next.delete(cartId); + else next.add(cartId); + return next; + }); + }} + onDeleteItem={handleDeleteItem} + onUpdateQuantity={handleUpdateQuantity} /> ); })} @@ -681,6 +852,11 @@ function Card({ cart, codeFieldName, parentComponentId, + isCartListMode, + isSelected, + onToggleSelect, + onDeleteItem, + onUpdateQuantity, }: { row: RowData; template?: CardTemplateConfig; @@ -694,6 +870,11 @@ function Card({ cart: ReturnType; codeFieldName?: string; parentComponentId?: string; + isCartListMode?: boolean; + isSelected?: boolean; + onToggleSelect?: () => void; + onDeleteItem?: (cartId: string) => void; + onUpdateQuantity?: (cartId: string, quantity: number, unit?: string, entries?: PackageEntry[]) => void; }) { const header = template?.header; const image = template?.image; @@ -712,14 +893,24 @@ function Card({ const isCarted = cart.isItemInCart(rowKey); const existingCartItem = cart.getCartItem(rowKey); - // DB에서 로드된 장바구니 품목이면 입력값 복원 + // DB에서 로드된 장바구니 품목이면 입력값 복원 (기본 모드) useEffect(() => { + if (isCartListMode) return; if (existingCartItem && existingCartItem._origin === "db") { setInputValue(existingCartItem.quantity); setPackageUnit(existingCartItem.packageUnit); setPackageEntries(existingCartItem.packageEntries || []); } - }, [existingCartItem?._origin, existingCartItem?.quantity, existingCartItem?.packageUnit]); + }, [isCartListMode, existingCartItem?._origin, existingCartItem?.quantity, existingCartItem?.packageUnit]); + + // 장바구니 목록 모드: __cart_quantity에서 초기값 복원 + useEffect(() => { + if (!isCartListMode) return; + const cartQty = Number(row.__cart_quantity) || 0; + setInputValue(cartQty); + const cartUnit = row.__cart_package_unit ? String(row.__cart_package_unit) : undefined; + setPackageUnit(cartUnit); + }, [isCartListMode, row.__cart_quantity, row.__cart_package_unit]); const imageUrl = image?.enabled && image?.imageColumn && row[image.imageColumn] @@ -771,6 +962,9 @@ function Card({ setInputValue(value); setPackageUnit(unit); setPackageEntries(entries || []); + if (isCartListMode) { + onUpdateQuantity?.(String(row.__cart_id), value, unit, entries); + } }; // 담기: 로컬 상태에만 추가 + 연결 시스템으로 카운트 전달 @@ -806,6 +1000,23 @@ function Card({ } }; + // 장바구니 목록 모드: 개별 삭제 + const handleCartDelete = async (e: React.MouseEvent) => { + e.stopPropagation(); + const cartId = String(row.__cart_id); + if (!cartId) return; + + const ok = window.confirm("이 항목을 장바구니에서 삭제하시겠습니까?"); + if (!ok) return; + + try { + await dataApi.deleteRecord("cart_items", cartId); + onDeleteItem?.(cartId); + } catch { + toast.error("삭제에 실패했습니다."); + } + }; + // pop-icon 스타일 담기/취소 버튼 아이콘 크기 (스케일 반영) const iconSize = Math.max(14, Math.round(18 * (scaled.bodyTextSize / 11))); const cartLabel = cartAction?.label || "담기"; @@ -815,22 +1026,43 @@ function Card({ onSelect?.(row); }; + // 카드 테두리: 장바구니 목록 모드에서는 선택 상태, 기본 모드에서는 담기 상태 기준 + const borderClass = isCartListMode + ? isSelected + ? "border-primary border-2 hover:border-primary/80" + : "hover:border-2 hover:border-blue-500" + : isCarted + ? "border-emerald-500 border-2 hover:border-emerald-600" + : "hover:border-2 hover:border-blue-500"; + + // 카드 헤더 배경: 장바구니 목록 모드에서는 선택 상태, 기본 모드에서는 담기 상태 기준 + const headerBgClass = isCartListMode + ? isSelected ? "bg-primary/10 dark:bg-primary/20" : "bg-muted/30" + : isCarted ? "bg-emerald-50 dark:bg-emerald-950/30" : "bg-muted/30"; + return (
{ if (e.key === "Enter" || e.key === " ") handleCardClick(); }} > + {/* 장바구니 목록 모드: 체크박스 */} + {isCartListMode && ( + { e.stopPropagation(); onToggleSelect?.(); }} + onClick={(e) => e.stopPropagation()} + className="absolute left-2 top-2 z-10 h-4 w-4 rounded border-gray-300" + /> + )} + {/* 헤더 영역 */} {(codeValue !== null || titleValue !== null) && ( -
+
{codeValue !== null && (
- {/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (각각 독립 ON/OFF) */} - {(inputField?.enabled || cartAction) && ( + {/* 오른쪽: 수량 버튼 + 담기/취소/삭제 버튼 */} + {(inputField?.enabled || cartAction || isCartListMode) && (
)} - {/* 담기/취소 버튼 (cartAction 존재 시 항상 표시) */} - {cartAction && ( + {/* 장바구니 목록 모드: 삭제 버튼 */} + {isCartListMode && ( + + )} + + {/* 기본 모드: 담기/취소 버튼 (cartAction 존재 시) */} + {!isCartListMode && cartAction && ( <> {isCarted ? ( @@ -183,7 +186,16 @@ export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentC /> )} {activeTab === "template" && ( - + isCartListMode ? ( +
+
+

원본 화면의 카드 설정을 자동으로 사용합니다

+

카드 디자인을 변경하려면 원본 화면에서 수정하세요

+
+
+ ) : ( + + ) )}
@@ -299,46 +311,58 @@ function BasicSettingsTab({ } }, [currentMode]); // eslint-disable-line react-hooks/exhaustive-deps + const isCartListMode = !!config.cartListMode?.enabled; + const updateDataSource = (partial: Partial) => { onUpdate({ dataSource: { ...dataSource, ...partial } }); }; return (
- {/* 테이블 선택 */} - -
-
- - { - onUpdate({ - dataSource: { - tableName: val, - joins: undefined, - filters: undefined, - sort: undefined, - limit: undefined, - }, - cardTemplate: DEFAULT_TEMPLATE, - }); - }} - /> -
- - {dataSource.tableName && ( -
- - {dataSource.tableName} -
- )} -
+ {/* 장바구니 목록 모드 */} + + onUpdate({ cartListMode })} + /> - {/* 조인 설정 (테이블 선택 시만 표시) */} - {dataSource.tableName && ( + {/* 테이블 선택 (장바구니 모드 시 숨김) */} + {!isCartListMode && ( + +
+
+ + { + onUpdate({ + dataSource: { + tableName: val, + joins: undefined, + filters: undefined, + sort: undefined, + limit: undefined, + }, + cardTemplate: DEFAULT_TEMPLATE, + }); + }} + /> +
+ + {dataSource.tableName && ( +
+ + {dataSource.tableName} +
+ )} +
+
+ )} + + {/* 조인 설정 (장바구니 모드 시 숨김) */} + {!isCartListMode && dataSource.tableName && ( )} - {/* 정렬 기준 (테이블 선택 시만 표시) */} - {dataSource.tableName && ( + {/* 정렬 기준 (장바구니 모드 시 숨김) */} + {!isCartListMode && dataSource.tableName && ( void; +}) { + const mode: CartListModeConfig = cartListMode || { enabled: false }; + const [screens, setScreens] = useState<{ id: number; name: string }[]>([]); + + useEffect(() => { + screenApi + .getScreens({ size: 500 }) + .then((res) => { + if (res?.data) { + setScreens( + res.data.map((s) => ({ + id: s.screenId, + name: s.screenName || `화면 ${s.screenId}`, + })) + ); + } + }) + .catch(() => {}); + }, []); + + return ( +
+
+ + onUpdate({ ...mode, enabled })} + /> +
+ +

+ 활성화하면 cart_items 테이블에서 데이터를 조회하고, + 원본 화면의 카드 설정을 자동으로 상속합니다. +

+ + {mode.enabled && ( + <> + {/* 원본 화면 선택 */} +
+ + +

+ 품목을 담았던 화면을 선택하면 카드 디자인이 자동으로 적용됩니다. +

+
+ + {/* 장바구니 구분값 */} +
+ + onUpdate({ ...mode, cartType: e.target.value })} + placeholder="예: purchase_inbound" + className="mt-1 h-7 text-xs" + /> +

+ 원본 화면의 담기 버튼에 설정한 구분값과 동일하게 입력하세요. +

+
+ + {/* 상태 필터 */} +
+ + +
+ + )} +
+ ); +} + // ===== 헤더 설정 섹션 ===== function HeaderSettingsSection({ diff --git a/frontend/lib/registry/pop-components/pop-card-list/index.tsx b/frontend/lib/registry/pop-components/pop-card-list/index.tsx index 738dfa4c..e78782e2 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/index.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/index.tsx @@ -63,10 +63,12 @@ PopComponentRegistry.registerComponent({ { key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 카드의 행 데이터를 전달" }, { key: "cart_updated", label: "장바구니 상태", type: "event", description: "장바구니 변경 시 count/isDirty 전달" }, { key: "cart_save_completed", label: "저장 완료", type: "event", description: "장바구니 DB 저장 완료 후 결과 전달" }, + { key: "selected_items", label: "선택된 항목", type: "value", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" }, ], receivable: [ { key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" }, { key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" }, + { key: "confirm_trigger", label: "확정 트리거", type: "event", description: "입고 확정 시 수정된 수량 일괄 반영 + 선택 항목 전달" }, ], }, touchOptimized: true, diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index d3c77233..6b10982d 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -608,6 +608,15 @@ export interface CardResponsiveConfig { fields?: Record; } +// ----- 장바구니 목록 모드 설정 ----- + +export interface CartListModeConfig { + enabled: boolean; + sourceScreenId?: number; + cartType?: string; + statusFilter?: string; +} + // ----- pop-card-list 전체 설정 ----- export interface PopCardListConfig { @@ -620,10 +629,11 @@ export interface PopCardListConfig { gridColumns?: number; gridRows?: number; - // 반응형 표시 설정 responsiveDisplay?: CardResponsiveConfig; inputField?: CardInputFieldConfig; packageConfig?: CardPackageConfig; cartAction?: CardCartActionConfig; + + cartListMode?: CartListModeConfig; } From c1cf31f57b9340349e0fd3d9eb1f2bb68b476b23 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 27 Feb 2026 15:13:49 +0900 Subject: [PATCH 03/13] =?UTF-8?q?fix(pop-card-list):=20=EC=9E=A5=EB=B0=94?= =?UTF-8?q?=EA=B5=AC=EB=8B=88=20=EB=AA=A8=EB=93=9C=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?UI=20=EA=B0=9C=EC=84=A0=20+=20=EB=B2=84=EA=B7=B8=205=EA=B1=B4?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - screenApi 필드명 수정 (screenId/screenName) - 레이아웃 components Record -> Object.values() 변환 - config 키 수정 (props -> config) - SelectItem 빈 값 방어 (cartType || __comp_id) - cartType 수동입력 -> 원본 컴포넌트 Select 자동 로드 - 상태 필터 UI 제거 (in_cart 고정) - 설정 미완료 가드 완화 (sourceScreenId만 필수) --- .../pop-card-list/PopCardListComponent.tsx | 40 +++--- .../pop-card-list/PopCardListConfig.tsx | 130 +++++++++++++----- 2 files changed, 117 insertions(+), 53 deletions(-) diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx index 0a7f4f9e..f89e44e6 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx @@ -498,8 +498,8 @@ export function PopCardListComponent({ if (isCartListMode) { const cartListMode = config!.cartListMode!; - // 설정 미완료 시 데이터 조회하지 않음 - if (!cartListMode.sourceScreenId || !cartListMode.cartType) { + // 원본 화면 미선택 시 데이터 조회하지 않음 + if (!cartListMode.sourceScreenId) { setLoading(false); setRows([]); return; @@ -513,27 +513,33 @@ export function PopCardListComponent({ if (cartListMode.sourceScreenId) { try { const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId); - const components = layoutJson?.components || []; - const matched = components.find( - (c: any) => - c.type === "pop-card-list" && - c.props?.cartAction?.cartType === cartListMode.cartType - ); - if (matched?.props?.cardTemplate) { - setInheritedTemplate(matched.props.cardTemplate); + const componentsMap = layoutJson?.components || {}; + const componentList = Object.values(componentsMap) as any[]; + const matched = cartListMode.cartType + ? componentList.find( + (c: any) => + c.type === "pop-card-list" && + c.config?.cartAction?.cartType === cartListMode.cartType + ) + : componentList.find((c: any) => c.type === "pop-card-list"); + if (matched?.config?.cardTemplate) { + setInheritedTemplate(matched.config.cardTemplate); } } catch { // 레이아웃 로드 실패 시 config.cardTemplate 폴백 } } - // cart_items 조회 + // cart_items 조회 (cartType이 있으면 필터, 없으면 전체) + const cartFilters: Record = { + status: cartListMode.statusFilter || "in_cart", + }; + if (cartListMode.cartType) { + cartFilters.cart_type = cartListMode.cartType; + } const result = await dataApi.getTableData("cart_items", { size: 500, - filters: { - cart_type: cartListMode.cartType, - status: cartListMode.statusFilter || "in_cart", - }, + filters: cartFilters, }); const parsed = (result.data || []).map(parseCartRow); @@ -680,10 +686,10 @@ export function PopCardListComponent({ ref={containerRef} className={`flex h-full w-full flex-col ${className || ""}`} > - {isCartListMode && (!config?.cartListMode?.sourceScreenId || !config?.cartListMode?.cartType) ? ( + {isCartListMode && !config?.cartListMode?.sourceScreenId ? (

- 원본 화면과 장바구니 구분값을 설정해주세요. + 원본 화면을 선택해주세요.

) : !isCartListMode && !dataSource?.tableName ? ( diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx index f0c09a00..eb1f565e 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx @@ -864,6 +864,12 @@ function CollapsibleSection({ // ===== 장바구니 목록 모드 설정 ===== +interface SourceCardListInfo { + componentId: string; + label: string; + cartType: string; +} + function CartListModeSection({ cartListMode, onUpdate, @@ -873,7 +879,10 @@ function CartListModeSection({ }) { const mode: CartListModeConfig = cartListMode || { enabled: false }; const [screens, setScreens] = useState<{ id: number; name: string }[]>([]); + const [sourceCardLists, setSourceCardLists] = useState([]); + const [loadingComponents, setLoadingComponents] = useState(false); + // 화면 목록 로드 useEffect(() => { screenApi .getScreens({ size: 500 }) @@ -890,6 +899,52 @@ function CartListModeSection({ .catch(() => {}); }, []); + // 원본 화면 선택 시 -> 해당 화면의 pop-card-list 컴포넌트 목록 로드 + useEffect(() => { + if (!mode.sourceScreenId) { + setSourceCardLists([]); + return; + } + setLoadingComponents(true); + screenApi + .getLayoutPop(mode.sourceScreenId) + .then((layoutJson: any) => { + const componentsMap = layoutJson?.components || {}; + const componentList = Object.values(componentsMap) as any[]; + const cardLists: SourceCardListInfo[] = componentList + .filter((c: any) => c.type === "pop-card-list") + .map((c: any) => ({ + componentId: c.id || "", + label: c.label || c.config?.cartAction?.cartType || "카드 목록", + cartType: c.config?.cartAction?.cartType || "", + })); + setSourceCardLists(cardLists); + }) + .catch(() => { + setSourceCardLists([]); + }) + .finally(() => setLoadingComponents(false)); + }, [mode.sourceScreenId]); + + const handleScreenChange = (val: string) => { + const screenId = val === "__none__" ? undefined : Number(val); + onUpdate({ ...mode, sourceScreenId: screenId, cartType: undefined }); + }; + + const handleComponentSelect = (val: string) => { + if (val === "__none__") { + onUpdate({ ...mode, cartType: undefined }); + return; + } + // cartType 직접 매칭 또는 componentId 매칭 (__comp_ 접두사) + const found = val.startsWith("__comp_") + ? sourceCardLists.find((c) => c.componentId === val.replace("__comp_", "")) + : sourceCardLists.find((c) => c.cartType === val); + if (found) { + onUpdate({ ...mode, cartType: found.cartType || undefined }); + } + }; + return (
@@ -912,9 +967,7 @@ function CartListModeSection({ -

- 품목을 담았던 화면을 선택하면 카드 디자인이 자동으로 적용됩니다. -

- {/* 장바구니 구분값 */} -
- - onUpdate({ ...mode, cartType: e.target.value })} - placeholder="예: purchase_inbound" - className="mt-1 h-7 text-xs" - /> -

- 원본 화면의 담기 버튼에 설정한 구분값과 동일하게 입력하세요. -

-
- - {/* 상태 필터 */} -
- - -
+ {/* 원본 컴포넌트 선택 (원본 화면에서 자동 로드) */} + {mode.sourceScreenId && ( +
+ + {loadingComponents ? ( +
+ 로딩 중... +
+ ) : sourceCardLists.length === 0 ? ( +
+ 원본 화면에 담기 설정이 있는 카드 목록이 없습니다. +
+ ) : ( + + )} +

+ 원본 화면의 카드 디자인과 장바구니 구분값이 자동으로 적용됩니다. +

+
+ )} )}
From 220e05d2ae7c949d335d648211fae054b16c80b1 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 27 Feb 2026 15:16:37 +0900 Subject: [PATCH 04/13] =?UTF-8?q?fix(pop-card-list):=20=EC=9B=90=EB=B3=B8?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EC=95=88=20=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CartListModeConfig에 sourceComponentId 추가 - cartType이 빈 문자열인 경우 componentId로 매칭 - Select value: sourceComponentId 기반 안정적 매칭 - 런타임: sourceComponentId > cartType > 첫 번째 순으로 폴백 원인: 4160 화면의 cartAction에 cartType 미설정 -> 빈 문자열 -> Select 저장 시 undefined 순환 -> 선택 불가 --- .../pop-card-list/PopCardListComponent.tsx | 17 ++++++++++------- .../pop-card-list/PopCardListConfig.tsx | 15 +++++++++++---- frontend/lib/registry/pop-components/types.ts | 1 + 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx index f89e44e6..585bea94 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx @@ -515,13 +515,16 @@ export function PopCardListComponent({ const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId); const componentsMap = layoutJson?.components || {}; const componentList = Object.values(componentsMap) as any[]; - const matched = cartListMode.cartType - ? componentList.find( - (c: any) => - c.type === "pop-card-list" && - c.config?.cartAction?.cartType === cartListMode.cartType - ) - : componentList.find((c: any) => c.type === "pop-card-list"); + // sourceComponentId > cartType > 첫 번째 pop-card-list 순으로 매칭 + const matched = cartListMode.sourceComponentId + ? componentList.find((c: any) => c.id === cartListMode.sourceComponentId) + : cartListMode.cartType + ? componentList.find( + (c: any) => + c.type === "pop-card-list" && + c.config?.cartAction?.cartType === cartListMode.cartType + ) + : componentList.find((c: any) => c.type === "pop-card-list"); if (matched?.config?.cardTemplate) { setInheritedTemplate(matched.config.cardTemplate); } diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx index eb1f565e..696f4821 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx @@ -933,15 +933,18 @@ function CartListModeSection({ const handleComponentSelect = (val: string) => { if (val === "__none__") { - onUpdate({ ...mode, cartType: undefined }); + onUpdate({ ...mode, cartType: undefined, sourceComponentId: undefined }); return; } - // cartType 직접 매칭 또는 componentId 매칭 (__comp_ 접두사) const found = val.startsWith("__comp_") ? sourceCardLists.find((c) => c.componentId === val.replace("__comp_", "")) : sourceCardLists.find((c) => c.cartType === val); if (found) { - onUpdate({ ...mode, cartType: found.cartType || undefined }); + onUpdate({ + ...mode, + sourceComponentId: found.componentId, + cartType: found.cartType || undefined, + }); } }; @@ -997,7 +1000,11 @@ function CartListModeSection({
) : ( + + + + + {targetCandidates.map((c) => ( + + {c.label || c.id} + + ))} + + +
+ + +
+ ); +} + +// ======================================== +// 필터 연결 폼 (검색 컴포넌트용: 기존 UI 유지) +// ======================================== + +interface FilterConnectionFormProps { component: PopComponentDefinitionV5; meta: ComponentConnectionMeta; allComponents: PopComponentDefinitionV5[]; @@ -232,7 +364,7 @@ interface ConnectionFormProps { submitLabel: string; } -function ConnectionForm({ +function FilterConnectionForm({ component, meta, allComponents, @@ -240,7 +372,7 @@ function ConnectionForm({ onSubmit, onCancel, submitLabel, -}: ConnectionFormProps) { +}: FilterConnectionFormProps) { const [selectedOutput, setSelectedOutput] = React.useState( initial?.sourceOutput || meta.sendable[0]?.key || "" ); @@ -272,32 +404,26 @@ function ConnectionForm({ ? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta : null; - // 보내는 값 + 받는 컴포넌트가 결정되면 받는 방식 자동 매칭 React.useEffect(() => { if (!selectedOutput || !targetMeta?.receivable?.length) return; - // 이미 선택된 값이 있으면 건드리지 않음 if (selectedTargetInput) return; const receivables = targetMeta.receivable; - // 1) 같은 key가 있으면 자동 매칭 const exactMatch = receivables.find((r) => r.key === selectedOutput); if (exactMatch) { setSelectedTargetInput(exactMatch.key); return; } - // 2) receivable이 1개뿐이면 자동 선택 if (receivables.length === 1) { setSelectedTargetInput(receivables[0].key); } }, [selectedOutput, targetMeta, selectedTargetInput]); - // 화면에 표시 중인 컬럼 const displayColumns = React.useMemo( () => extractDisplayColumns(targetComp || undefined), [targetComp] ); - // DB 테이블 전체 컬럼 (비동기 조회) const tableName = React.useMemo( () => extractTableName(targetComp || undefined), [targetComp] @@ -324,7 +450,6 @@ function ConnectionForm({ return () => { cancelled = true; }; }, [tableName]); - // 표시 컬럼과 데이터 전용 컬럼 분리 const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]); const dataOnlyColumns = React.useMemo( () => allDbColumns.filter((c) => !displaySet.has(c)), @@ -388,7 +513,6 @@ function ConnectionForm({

새 연결 추가

)} - {/* 보내는 값 */}
보내는 값
- {/* 받는 컴포넌트 */}
받는 컴포넌트 setFilterMode(v)}> @@ -540,7 +658,6 @@ function ConnectionForm({
)} - {/* 제출 버튼 */} +
+ ); +} + +function SingleRuleEditor({ + rule, + idx, + tables, + columns, + onLoadColumns, + onUpdate, + onRemove, +}: { + rule: StatusChangeRule; + idx: number; + tables: TableInfo[]; + columns: ColumnInfo[]; + onLoadColumns: (tableName: string) => void; + onUpdate: (partial: Partial) => void; + onRemove: () => void; +}) { + useEffect(() => { + if (rule.targetTable) onLoadColumns(rule.targetTable); + }, [rule.targetTable]); // eslint-disable-line react-hooks/exhaustive-deps + + const conditions = rule.conditionalValue?.conditions ?? []; + const defaultValue = rule.conditionalValue?.defaultValue ?? ""; + + const updateCondition = (cIdx: number, partial: Partial<(typeof conditions)[0]>) => { + const next = [...conditions]; + next[cIdx] = { ...next[cIdx], ...partial }; + onUpdate({ + conditionalValue: { ...rule.conditionalValue, conditions: next, defaultValue }, + }); + }; + + const removeCondition = (cIdx: number) => { + const next = [...conditions]; + next.splice(cIdx, 1); + onUpdate({ + conditionalValue: { ...rule.conditionalValue, conditions: next, defaultValue }, + }); + }; + + const addCondition = () => { + onUpdate({ + conditionalValue: { + ...rule.conditionalValue, + conditions: [...conditions, { whenColumn: "", operator: "=" as const, whenValue: "", thenValue: "" }], + defaultValue, + }, + }); + }; + + return ( +
+
+ 규칙 {idx + 1} + +
+ + {/* 대상 테이블 */} +
+ + onUpdate({ targetTable: v, targetColumn: "" })} + /> +
+ + {/* 변경 컬럼 */} + {rule.targetTable && ( +
+ + onUpdate({ targetColumn: v })} + /> +
+ )} + + {/* 조회 키 */} + {rule.targetColumn && ( +
+
+ + +
+ {(rule.lookupMode ?? "auto") === "auto" ? ( +

+ {rule.targetTable === "cart_items" + ? `카드 항목.__cart_id → ${rule.targetTable}.id` + : `카드 항목.row_key → ${rule.targetTable}.${columns.find(c => c.isPrimaryKey)?.name ?? "PK(조회중)"}`} +

+ ) : ( +
+ + + onUpdate({ manualPkColumn: v })} + placeholder="대상 PK 컬럼" + /> +
+ )} +
+ )} + + {/* 변경 값 타입 */} + {rule.targetColumn && ( + <> +
+ +
+ + +
+
+ + {/* 고정값 */} + {rule.valueType === "fixed" && ( +
+ onUpdate({ fixedValue: e.target.value })} + className="h-7 text-xs" + placeholder="변경할 값 입력" + /> +
+ )} + + {/* 조건부 */} + {rule.valueType === "conditional" && ( +
+ {conditions.map((cond, cIdx) => ( +
+
+ 만약 + updateCondition(cIdx, { whenColumn: v })} + placeholder="컬럼" + /> + + updateCondition(cIdx, { whenValue: e.target.value })} + className="h-7 w-16 text-[10px]" + placeholder="값" + /> + +
+
+ 이면 -> + updateCondition(cIdx, { thenValue: e.target.value })} + className="h-7 text-[10px]" + placeholder="변경할 값" + /> +
+
+ ))} + +
+ 그 외 -> + + onUpdate({ + conditionalValue: { ...rule.conditionalValue, conditions, defaultValue: e.target.value }, + }) + } + className="h-7 text-[10px]" + placeholder="기본값" + /> +
+
+ )} + + + )} +
+ ); +} + // 레지스트리 등록 PopComponentRegistry.registerComponent({ id: "pop-button", @@ -1486,11 +1964,14 @@ PopComponentRegistry.registerComponent({ } as PopButtonConfig, connectionMeta: { sendable: [ - { key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 일괄 저장 요청 (카드 목록에 전달)" }, + { key: "cart_save_trigger", label: "저장 요청", type: "event", category: "event", description: "장바구니 일괄 저장 요청 (카드 목록에 전달)" }, + { key: "collect_data", label: "데이터 수집 요청", type: "event", category: "event", description: "연결된 컴포넌트에 데이터+매핑 수집 요청" }, + { key: "action_completed", label: "액션 완료", type: "event", category: "event", description: "확정/저장 완료 후 결과 전달" }, ], receivable: [ - { key: "cart_updated", label: "장바구니 상태", type: "event", description: "카드 목록에서 장바구니 변경 시 count/isDirty 수신" }, - { key: "cart_save_completed", label: "저장 완료", type: "event", description: "카드 목록에서 DB 저장 완료 시 수신" }, + { key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "카드 목록에서 장바구니 변경 시 count/isDirty 수신" }, + { key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "카드 목록에서 DB 저장 완료 시 수신" }, + { key: "collected_data", label: "수집된 데이터", type: "event", category: "event", description: "컴포넌트에서 수집한 데이터+매핑 응답" }, ], }, touchOptimized: true, diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx index 585bea94..4ba1abc1 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx @@ -29,7 +29,8 @@ import type { CardPresetSpec, CartItem, PackageEntry, - CartListModeConfig, + CollectDataRequest, + CollectedDataResponse, } from "../types"; import { DEFAULT_CARD_IMAGE, @@ -183,27 +184,34 @@ export function PopCardListComponent({ currentColSpan, onRequestResize, }: PopCardListComponentProps) { - const isHorizontalMode = (config?.scrollDirection || "vertical") === "horizontal"; - const maxGridColumns = config?.gridColumns || 2; - const configGridRows = config?.gridRows || 3; - const dataSource = config?.dataSource; - const template = config?.cardTemplate; - const { subscribe, publish } = usePopEvent(screenId || "default"); const router = useRouter(); - // 장바구니 DB 동기화 - const sourceTableName = dataSource?.tableName || ""; - const cartType = config?.cartAction?.cartType; - const cart = useCartSync(screenId || "", sourceTableName, cartType); - // 장바구니 목록 모드 플래그 및 상태 const isCartListMode = config?.cartListMode?.enabled === true; - const [inheritedTemplate, setInheritedTemplate] = useState(null); + const [inheritedConfig, setInheritedConfig] = useState | null>(null); const [selectedKeys, setSelectedKeys] = useState>(new Set()); - // 장바구니 목록 모드에서는 원본 화면의 템플릿을 사용, 폴백으로 자체 설정 - const effectiveTemplate = (isCartListMode && inheritedTemplate) ? inheritedTemplate : template; + // 장바구니 목록 모드: 원본 화면의 설정 전체를 병합 (cardTemplate, inputField, packageConfig, cardSize 등) + const effectiveConfig = useMemo(() => { + if (!isCartListMode || !inheritedConfig) return config; + return { + ...config, + ...inheritedConfig, + cartListMode: config?.cartListMode, + dataSource: config?.dataSource, + } as PopCardListConfig; + }, [config, inheritedConfig, isCartListMode]); + + const isHorizontalMode = (effectiveConfig?.scrollDirection || "vertical") === "horizontal"; + const maxGridColumns = effectiveConfig?.gridColumns || 2; + const configGridRows = effectiveConfig?.gridRows || 3; + const dataSource = effectiveConfig?.dataSource; + const effectiveTemplate = effectiveConfig?.cardTemplate; + + // 장바구니 DB 동기화 (장바구니 목록 모드에서는 비활성화) + const sourceTableName = (!isCartListMode && dataSource?.tableName) || ""; + const cart = useCartSync(screenId || "", sourceTableName); // 데이터 상태 const [rows, setRows] = useState([]); @@ -311,7 +319,7 @@ export function PopCardListComponent({ const missingImageCountRef = useRef(0); - const cardSizeKey = config?.cardSize || "large"; + const cardSizeKey = effectiveConfig?.cardSize || "large"; const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || CARD_PRESET_SPECS.large; // 브레이크포인트별 열 제한: colSpan >= 8이면 config 최대값 허용, 그 외 1열 @@ -509,36 +517,26 @@ export function PopCardListComponent({ setLoading(true); setError(null); try { - // 원본 화면 레이아웃에서 cardTemplate 상속 - if (cartListMode.sourceScreenId) { - try { - const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId); - const componentsMap = layoutJson?.components || {}; - const componentList = Object.values(componentsMap) as any[]; - // sourceComponentId > cartType > 첫 번째 pop-card-list 순으로 매칭 - const matched = cartListMode.sourceComponentId - ? componentList.find((c: any) => c.id === cartListMode.sourceComponentId) - : cartListMode.cartType - ? componentList.find( - (c: any) => - c.type === "pop-card-list" && - c.config?.cartAction?.cartType === cartListMode.cartType - ) - : componentList.find((c: any) => c.type === "pop-card-list"); - if (matched?.config?.cardTemplate) { - setInheritedTemplate(matched.config.cardTemplate); - } - } catch { - // 레이아웃 로드 실패 시 config.cardTemplate 폴백 + // 원본 화면 레이아웃에서 설정 전체 상속 (cardTemplate, inputField, packageConfig, cardSize 등) + try { + const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId); + const componentsMap = layoutJson?.components || {}; + const componentList = Object.values(componentsMap) as any[]; + const matched = cartListMode.sourceComponentId + ? componentList.find((c: any) => c.id === cartListMode.sourceComponentId) + : componentList.find((c: any) => c.type === "pop-card-list"); + if (matched?.config) { + setInheritedConfig(matched.config); } + } catch { + // 레이아웃 로드 실패 시 자체 config 폴백 } - // cart_items 조회 (cartType이 있으면 필터, 없으면 전체) const cartFilters: Record = { status: cartListMode.statusFilter || "in_cart", }; - if (cartListMode.cartType) { - cartFilters.cart_type = cartListMode.cartType; + if (cartListMode.sourceScreenId) { + cartFilters.screen_id = String(cartListMode.sourceScreenId); } const result = await dataApi.getTableData("cart_items", { size: 500, @@ -572,10 +570,11 @@ export function PopCardListComponent({ missingImageCountRef.current = 0; try { + // 서버에는 = 연산자 필터만 전달, 나머지는 클라이언트 후처리 const filters: Record = {}; if (dataSource.filters && dataSource.filters.length > 0) { dataSource.filters.forEach((f) => { - if (f.column && f.value) { + if (f.column && f.value && (!f.operator || f.operator === "=")) { filters[f.column] = f.value; } }); @@ -604,7 +603,31 @@ export function PopCardListComponent({ filters: Object.keys(filters).length > 0 ? filters : undefined, }); - setRows(result.data || []); + let fetchedRows = result.data || []; + + // 서버에서 처리하지 못한 연산자 필터 클라이언트 후처리 + const clientFilters = (dataSource.filters || []).filter( + (f) => f.column && f.value && f.operator && f.operator !== "=" + ); + if (clientFilters.length > 0) { + fetchedRows = fetchedRows.filter((row) => + clientFilters.every((f) => { + const cellVal = row[f.column]; + const filterVal = f.value; + switch (f.operator) { + case "!=": return String(cellVal ?? "") !== filterVal; + case ">": return Number(cellVal) > Number(filterVal); + case ">=": return Number(cellVal) >= Number(filterVal); + case "<": return Number(cellVal) < Number(filterVal); + case "<=": return Number(cellVal) <= Number(filterVal); + case "like": return String(cellVal ?? "").toLowerCase().includes(filterVal.toLowerCase()); + default: return true; + } + }) + ); + } + + setRows(fetchedRows); } catch (err) { const message = err instanceof Error ? err.message : "데이터 조회 실패"; setError(message); @@ -654,10 +677,49 @@ export function PopCardListComponent({ })); }, []); + // 데이터 수집 요청 수신: 버튼에서 collect_data 요청 → 선택 항목 + 매핑 응답 + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__collect_data`, + (payload: unknown) => { + const request = (payload as Record)?.value as CollectDataRequest | undefined; + + const selectedItems = isCartListMode + ? filteredRows.filter(r => selectedKeys.has(String(r.__cart_id ?? ""))) + : rows; + + // CardListSaveMapping → SaveMapping 변환 + const sm = config?.saveMapping; + const mapping = sm?.targetTable && sm.mappings.length > 0 + ? { + targetTable: sm.targetTable, + columnMapping: Object.fromEntries( + sm.mappings + .filter(m => m.sourceField && m.targetColumn) + .map(m => [m.sourceField, m.targetColumn]) + ), + } + : null; + + const response: CollectedDataResponse = { + requestId: request?.requestId ?? "", + componentId: componentId, + componentType: "pop-card-list", + data: { items: selectedItems }, + mapping, + }; + + publish(`__comp_output__${componentId}__collected_data`, response); + } + ); + return unsub; + }, [componentId, subscribe, publish, isCartListMode, filteredRows, rows, selectedKeys]); + // 장바구니 목록 모드: 선택 항목 이벤트 발행 useEffect(() => { if (!componentId || !isCartListMode) return; - const selectedItems = filteredRows.filter(r => selectedKeys.has(String(r.__cart_id))); + const selectedItems = filteredRows.filter(r => selectedKeys.has(String(r.__cart_id ?? ""))); publish(`__comp_output__${componentId}__selected_items`, selectedItems); }, [selectedKeys, filteredRows, componentId, isCartListMode, publish]); @@ -720,15 +782,15 @@ export function PopCardListComponent({
0} + checked={selectedKeys.size === filteredRows.length && filteredRows.length > 0} onChange={(e) => { if (e.target.checked) { - setSelectedKeys(new Set(displayCards.map(r => String(r.__cart_id)))); + setSelectedKeys(new Set(filteredRows.map(r => String(r.__cart_id ?? "")))); } else { setSelectedKeys(new Set()); } }} - className="h-4 w-4 rounded border-gray-300" + className="h-4 w-4 rounded border-input" /> {selectedKeys.size > 0 ? `${selectedKeys.size}개 선택됨` : "전체 선택"} @@ -757,19 +819,20 @@ export function PopCardListComponent({ row={row} template={effectiveTemplate} scaled={scaled} - inputField={config?.inputField} - packageConfig={config?.packageConfig} - cartAction={config?.cartAction} + inputField={effectiveConfig?.inputField} + packageConfig={effectiveConfig?.packageConfig} + cartAction={effectiveConfig?.cartAction} publish={publish} router={router} onSelect={handleCardSelect} cart={cart} - codeFieldName={effectiveTemplate?.header?.codeField} + keyColumnName={effectiveConfig?.cartAction?.keyColumn || "id"} parentComponentId={componentId} isCartListMode={isCartListMode} - isSelected={selectedKeys.has(String(row.__cart_id))} + isSelected={selectedKeys.has(String(row.__cart_id ?? ""))} onToggleSelect={() => { - const cartId = String(row.__cart_id); + const cartId = row.__cart_id != null ? String(row.__cart_id) : ""; + if (!cartId) return; setSelectedKeys(prev => { const next = new Set(prev); if (next.has(cartId)) next.delete(cartId); @@ -859,7 +922,7 @@ function Card({ router, onSelect, cart, - codeFieldName, + keyColumnName, parentComponentId, isCartListMode, isSelected, @@ -877,7 +940,7 @@ function Card({ router: ReturnType; onSelect?: (row: RowData) => void; cart: ReturnType; - codeFieldName?: string; + keyColumnName?: string; parentComponentId?: string; isCartListMode?: boolean; isSelected?: boolean; @@ -897,8 +960,7 @@ function Card({ const codeValue = header?.codeField ? row[header.codeField] : null; const titleValue = header?.titleField ? row[header.titleField] : null; - // 장바구니 상태: codeField 값을 rowKey로 사용 - const rowKey = codeFieldName && row[codeFieldName] ? String(row[codeFieldName]) : ""; + const rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : ""; const isCarted = cart.isItemInCart(rowKey); const existingCartItem = cart.getCartItem(rowKey); @@ -1012,14 +1074,14 @@ function Card({ // 장바구니 목록 모드: 개별 삭제 const handleCartDelete = async (e: React.MouseEvent) => { e.stopPropagation(); - const cartId = String(row.__cart_id); + const cartId = row.__cart_id != null ? String(row.__cart_id) : ""; if (!cartId) return; const ok = window.confirm("이 항목을 장바구니에서 삭제하시겠습니까?"); if (!ok) return; try { - await dataApi.deleteRecord("cart_items", cartId); + await dataApi.updateRecord("cart_items", cartId, { status: "cancelled" }); onDeleteItem?.(cartId); } catch { toast.error("삭제에 실패했습니다."); @@ -1058,21 +1120,19 @@ function Card({ tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCardClick(); }} > - {/* 장바구니 목록 모드: 체크박스 */} - {isCartListMode && ( - { e.stopPropagation(); onToggleSelect?.(); }} - onClick={(e) => e.stopPropagation()} - className="absolute left-2 top-2 z-10 h-4 w-4 rounded border-gray-300" - /> - )} - {/* 헤더 영역 */} - {(codeValue !== null || titleValue !== null) && ( + {(codeValue !== null || titleValue !== null || isCartListMode) && (
+ {isCartListMode && ( + { e.stopPropagation(); onToggleSelect?.(); }} + onClick={(e) => e.stopPropagation()} + className="h-4 w-4 shrink-0 rounded border-input" + /> + )} {codeValue !== null && ( {inputValue.toLocaleString()} diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx index 696f4821..0e868711 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx @@ -9,7 +9,7 @@ */ import React, { useState, useEffect, useMemo } from "react"; -import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check, ChevronsUpDown } from "lucide-react"; +import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check } from "lucide-react"; import type { GridMode } from "@/components/pop/designer/types/pop-layout"; import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout"; import { Button } from "@/components/ui/button"; @@ -25,8 +25,6 @@ import { 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 { cn } from "@/lib/utils"; import type { PopCardListConfig, @@ -50,6 +48,8 @@ import type { CardResponsiveConfig, ResponsiveDisplayMode, CartListModeConfig, + CardListSaveMapping, + CardListSaveMappingEntry, } from "../types"; import { screenApi } from "@/lib/api/screen"; import { @@ -63,6 +63,7 @@ import { type TableInfo, type ColumnInfo, } from "../pop-dashboard/utils/dataFetcher"; +import { TableCombobox } from "../pop-shared/TableCombobox"; // ===== 테이블별 그룹화된 컬럼 ===== @@ -399,6 +400,42 @@ function BasicSettingsTab({ )} + {/* 필터 기준 (장바구니 모드 시 숨김) */} + {!isCartListMode && dataSource.tableName && ( + 0 + ? `${dataSource.filters.length}개` + : undefined + } + > + + + )} + + {/* 저장 매핑 (장바구니 모드일 때만) */} + {isCartListMode && ( + 0 + ? `${config.saveMapping.mappings.length}개` + : undefined + } + > + onUpdate({ saveMapping })} + cartListMode={config.cartListMode} + /> + + )} + {/* 레이아웃 설정 */}
@@ -667,99 +704,7 @@ function CardTemplateTab({ ); } -// ===== 테이블 검색 Combobox ===== - -function TableCombobox({ - tables, - value, - onSelect, -}: { - tables: TableInfo[]; - value: string; - onSelect: (tableName: string) => void; -}) { - const [open, setOpen] = useState(false); - const [search, setSearch] = useState(""); - - const selectedLabel = useMemo(() => { - const found = tables.find((t) => t.tableName === value); - return found ? (found.displayName || found.tableName) : ""; - }, [tables, value]); - - const filtered = useMemo(() => { - if (!search) return tables; - const q = search.toLowerCase(); - return tables.filter( - (t) => - t.tableName.toLowerCase().includes(q) || - (t.displayName && t.displayName.toLowerCase().includes(q)) - ); - }, [tables, search]); - - return ( - - - - - - - - - - 검색 결과가 없습니다. - - - {filtered.map((table) => ( - { - onSelect(table.tableName); - setOpen(false); - setSearch(""); - }} - className="text-xs" - > - -
- {table.displayName || table.tableName} - {table.displayName && ( - - {table.tableName} - - )} -
-
- ))} -
-
-
-
-
- ); -} +// TableCombobox: pop-shared/TableCombobox.tsx에서 import // ===== 테이블별 그룹화된 컬럼 셀렉트 ===== @@ -867,7 +812,6 @@ function CollapsibleSection({ interface SourceCardListInfo { componentId: string; label: string; - cartType: string; } function CartListModeSection({ @@ -915,8 +859,7 @@ function CartListModeSection({ .filter((c: any) => c.type === "pop-card-list") .map((c: any) => ({ componentId: c.id || "", - label: c.label || c.config?.cartAction?.cartType || "카드 목록", - cartType: c.config?.cartAction?.cartType || "", + label: c.label || "카드 목록", })); setSourceCardLists(cardLists); }) @@ -928,23 +871,18 @@ function CartListModeSection({ const handleScreenChange = (val: string) => { const screenId = val === "__none__" ? undefined : Number(val); - onUpdate({ ...mode, sourceScreenId: screenId, cartType: undefined }); + onUpdate({ ...mode, sourceScreenId: screenId }); }; const handleComponentSelect = (val: string) => { if (val === "__none__") { - onUpdate({ ...mode, cartType: undefined, sourceComponentId: undefined }); + onUpdate({ ...mode, sourceComponentId: undefined }); return; } - const found = val.startsWith("__comp_") - ? sourceCardLists.find((c) => c.componentId === val.replace("__comp_", "")) - : sourceCardLists.find((c) => c.cartType === val); + const compId = val.startsWith("__comp_") ? val.replace("__comp_", "") : val; + const found = sourceCardLists.find((c) => c.componentId === compId); if (found) { - onUpdate({ - ...mode, - sourceComponentId: found.componentId, - cartType: found.cartType || undefined, - }); + onUpdate({ ...mode, sourceComponentId: found.componentId }); } }; @@ -1000,11 +938,7 @@ function CartListModeSection({
) : ( )}

- 원본 화면의 카드 디자인과 장바구니 구분값이 자동으로 적용됩니다. + 원본 화면의 카드 디자인이 자동으로 적용됩니다.

)} @@ -2329,6 +2260,60 @@ function LimitSettingsSection({ ); } +// ===== 행 식별 키 컬럼 선택 ===== + +function KeyColumnSelect({ + tableName, + value, + onValueChange, +}: { + tableName?: string; + value: string; + onValueChange: (v: string) => void; +}) { + const [columns, setColumns] = useState([]); + + useEffect(() => { + if (tableName) { + fetchTableColumns(tableName).then(setColumns); + } else { + setColumns([]); + } + }, [tableName]); + + const options = useMemo(() => { + const seen = new Set(); + const unique: ColumnInfo[] = []; + const hasId = columns.some((c) => c.name === "id"); + if (!hasId) { + unique.push({ name: "id", type: "uuid", udtName: "uuid" }); + seen.add("id"); + } + for (const c of columns) { + if (!seen.has(c.name)) { + seen.add(c.name); + unique.push(c); + } + } + return unique; + }, [columns]); + + return ( + + ); +} + // ===== 담기 버튼 설정 섹션 ===== function CartActionSettingsSection({ @@ -2393,18 +2378,17 @@ function CartActionSettingsSection({
- {/* 장바구니 구분값 */} + {/* 행 식별 키 컬럼 */} {saveMode === "cart" && (
- - update({ cartType: e.target.value })} - placeholder="예: purchase_inbound" - className="mt-1 h-7 text-xs" + + update({ keyColumn: v })} />

- 장바구니 화면에서 이 값으로 필터링하여 해당 품목만 표시합니다. + 각 행을 고유하게 식별하는 컬럼입니다. 기본값: id (UUID)

)} @@ -2606,3 +2590,517 @@ function ResponsiveDisplayRow({
); } + +// ===== 필터 기준 섹션 (columnGroups 기반) ===== + +const FILTER_OPERATORS: { value: FilterOperator; label: string }[] = [ + { value: "=", label: "=" }, + { value: "!=", label: "!=" }, + { value: ">", label: ">" }, + { value: "<", label: "<" }, + { value: ">=", label: ">=" }, + { value: "<=", label: "<=" }, + { value: "like", label: "포함" }, +]; + +function FilterCriteriaSection({ + dataSource, + columnGroups, + onUpdate, +}: { + dataSource: CardListDataSource; + columnGroups: ColumnGroup[]; + onUpdate: (partial: Partial) => void; +}) { + const filters = dataSource.filters || []; + + const addFilter = () => { + const newFilter: CardColumnFilter = { column: "", operator: "=", value: "" }; + onUpdate({ filters: [...filters, newFilter] }); + }; + + const updateFilter = (index: number, updated: CardColumnFilter) => { + const next = [...filters]; + next[index] = updated; + onUpdate({ filters: next }); + }; + + const deleteFilter = (index: number) => { + const next = filters.filter((_, i) => i !== index); + onUpdate({ filters: next.length > 0 ? next : undefined }); + }; + + return ( +
+

+ 데이터 조회 시 적용할 필터 조건입니다. +

+ + {filters.length === 0 ? ( +
+

필터 조건이 없습니다

+
+ ) : ( +
+ {filters.map((filter, index) => ( +
+
+ updateFilter(index, { ...filter, column: val || "" })} + placeholder="컬럼 선택" + /> +
+ + updateFilter(index, { ...filter, value: e.target.value })} + placeholder="값" + className="h-7 flex-1 text-xs" + /> + +
+ ))} +
+ )} + + +
+ ); +} + +// ===== 저장 매핑 섹션 (장바구니 -> 대상 테이블) ===== + +const CART_META_FIELDS = [ + { value: "__cart_quantity", label: "입력 수량" }, + { value: "__cart_package_unit", label: "포장 단위" }, + { value: "__cart_package_entries", label: "포장 내역" }, + { value: "__cart_memo", label: "메모" }, + { value: "__cart_row_key", label: "원본 키" }, +]; + +interface CardDisplayedField { + sourceField: string; + label: string; + badge: string; +} + +function SaveMappingSection({ + saveMapping, + onUpdate, + cartListMode, +}: { + saveMapping?: CardListSaveMapping; + onUpdate: (mapping: CardListSaveMapping) => void; + cartListMode?: CartListModeConfig; +}) { + const mapping: CardListSaveMapping = saveMapping || { targetTable: "", mappings: [] }; + const [tables, setTables] = useState([]); + const [targetColumns, setTargetColumns] = useState([]); + const [sourceColumns, setSourceColumns] = useState([]); + const [sourceTableName, setSourceTableName] = useState(""); + const [cardDisplayedFields, setCardDisplayedFields] = useState([]); + + useEffect(() => { + fetchTableList().then(setTables); + }, []); + + // 원본 화면에서 테이블 컬럼 + 카드 템플릿 필드 추출 + useEffect(() => { + if (!cartListMode?.sourceScreenId) { + setSourceColumns([]); + setSourceTableName(""); + setCardDisplayedFields([]); + return; + } + + screenApi + .getLayoutPop(cartListMode.sourceScreenId) + .then((layoutJson: any) => { + const componentsMap = layoutJson?.components || {}; + const componentList = Object.values(componentsMap) as any[]; + + const matched = cartListMode.sourceComponentId + ? componentList.find((c: any) => c.id === cartListMode.sourceComponentId) + : componentList.find((c: any) => c.type === "pop-card-list"); + + const tableName = matched?.config?.dataSource?.tableName; + if (tableName) { + setSourceTableName(tableName); + fetchTableColumns(tableName).then(setSourceColumns); + } + + // 카드 템플릿에서 표시 중인 필드 추출 + const cardTemplate = matched?.config?.cardTemplate; + const inputFieldConfig = matched?.config?.inputField; + const packageConfig = matched?.config?.packageConfig; + const displayed: CardDisplayedField[] = []; + + if (cardTemplate?.header?.codeField) { + displayed.push({ + sourceField: cardTemplate.header.codeField, + label: cardTemplate.header.codeField, + badge: "헤더", + }); + } + if (cardTemplate?.header?.titleField) { + displayed.push({ + sourceField: cardTemplate.header.titleField, + label: cardTemplate.header.titleField, + badge: "헤더", + }); + } + for (const f of cardTemplate?.body?.fields || []) { + if (f.valueType === "column" && f.columnName) { + displayed.push({ + sourceField: f.columnName, + label: f.label || f.columnName, + badge: "본문", + }); + } + } + if (inputFieldConfig?.enabled) { + displayed.push({ + sourceField: "__cart_quantity", + label: "입력 수량", + badge: "입력", + }); + } + if (packageConfig?.enabled) { + displayed.push({ + sourceField: "__cart_package_unit", + label: "포장 단위", + badge: "포장", + }); + displayed.push({ + sourceField: "__cart_package_entries", + label: "포장 내역", + badge: "포장", + }); + } + + setCardDisplayedFields(displayed); + }) + .catch(() => { + setSourceColumns([]); + setSourceTableName(""); + setCardDisplayedFields([]); + }); + }, [cartListMode?.sourceScreenId, cartListMode?.sourceComponentId]); + + useEffect(() => { + if (mapping.targetTable) { + fetchTableColumns(mapping.targetTable).then(setTargetColumns); + } else { + setTargetColumns([]); + } + }, [mapping.targetTable]); + + // 카드에 표시된 필드 set (빠른 조회용) + const cardFieldSet = useMemo( + () => new Set(cardDisplayedFields.map((f) => f.sourceField)), + [cardDisplayedFields] + ); + + const getSourceFieldLabel = (field: string) => { + const cardField = cardDisplayedFields.find((f) => f.sourceField === field); + if (cardField) return cardField.label; + const meta = CART_META_FIELDS.find((f) => f.value === field); + if (meta) return meta.label; + return field; + }; + + const getFieldBadge = (field: string) => { + const cardField = cardDisplayedFields.find((f) => f.sourceField === field); + return cardField?.badge || null; + }; + + const isCartMeta = (field: string) => field.startsWith("__cart_"); + + const getSourceTableDisplayName = () => { + if (!sourceTableName) return "원본 데이터"; + const found = tables.find((t) => t.tableName === sourceTableName); + return found?.displayName || sourceTableName; + }; + + const mappedSourceFields = useMemo( + () => new Set(mapping.mappings.map((m) => m.sourceField)), + [mapping.mappings] + ); + + // 카드에 표시된 필드 중 아직 매핑되지 않은 것 + const unmappedCardFields = useMemo( + () => cardDisplayedFields.filter((f) => !mappedSourceFields.has(f.sourceField)), + [cardDisplayedFields, mappedSourceFields] + ); + + // 카드에 없고 매핑도 안 된 원본 컬럼 + const availableExtraSourceFields = useMemo( + () => sourceColumns.filter((col) => !cardFieldSet.has(col.name) && !mappedSourceFields.has(col.name)), + [sourceColumns, cardFieldSet, mappedSourceFields] + ); + + // 카드에 없고 매핑도 안 된 장바구니 메타 + const availableExtraCartFields = useMemo( + () => CART_META_FIELDS.filter((f) => !cardFieldSet.has(f.value) && !mappedSourceFields.has(f.value)), + [cardFieldSet, mappedSourceFields] + ); + + // 대상 테이블 선택 -> 카드 표시 필드 전체 자동 매핑 + const updateTargetTable = (targetTable: string) => { + fetchTableColumns(targetTable).then((targetCols) => { + setTargetColumns(targetCols); + + const targetNameSet = new Set(targetCols.map((c) => c.name)); + const autoMappings: CardListSaveMappingEntry[] = []; + + for (const field of cardDisplayedFields) { + autoMappings.push({ + sourceField: field.sourceField, + targetColumn: targetNameSet.has(field.sourceField) ? field.sourceField : "", + }); + } + + onUpdate({ targetTable, mappings: autoMappings }); + }); + }; + + const addFieldMapping = (sourceField: string) => { + const matched = targetColumns.find((tc) => tc.name === sourceField); + onUpdate({ + ...mapping, + mappings: [ + ...mapping.mappings, + { sourceField, targetColumn: matched?.name || "" }, + ], + }); + }; + + const updateEntry = (index: number, updated: CardListSaveMappingEntry) => { + const next = [...mapping.mappings]; + next[index] = updated; + onUpdate({ ...mapping, mappings: next }); + }; + + const deleteEntry = (index: number) => { + const next = mapping.mappings.filter((_, i) => i !== index); + onUpdate({ ...mapping, mappings: next }); + }; + + const autoMatchedCount = mapping.mappings.filter((m) => m.targetColumn).length; + + // 매핑 행 렌더링 (공용) + const renderMappingRow = (entry: CardListSaveMappingEntry, index: number) => { + const badge = getFieldBadge(entry.sourceField); + return ( +
+
+
+ + {getSourceFieldLabel(entry.sourceField)} + + {badge && ( + + {badge} + + )} +
+ {isCartMeta(entry.sourceField) ? ( + !badge && 장바구니 + ) : ( + + {entry.sourceField} + + )} +
+ + + +
+ +
+ + +
+ ); + }; + + // 매핑 목록을 카드필드 / 추가필드로 분리 + const cardMappings: { entry: CardListSaveMappingEntry; index: number }[] = []; + const extraMappings: { entry: CardListSaveMappingEntry; index: number }[] = []; + mapping.mappings.forEach((entry, index) => { + if (cardFieldSet.has(entry.sourceField)) { + cardMappings.push({ entry, index }); + } else { + extraMappings.push({ entry, index }); + } + }); + + return ( +
+

+ 대상 테이블을 선택하면 카드에 배치된 필드가 자동으로 매핑됩니다. +

+ +
+ + +
+ + {!mapping.targetTable ? ( +
+

대상 테이블을 먼저 선택하세요

+
+ ) : ( + <> + {/* 자동 매핑 안내 */} + {autoMatchedCount > 0 && ( +
+ + + 이름 일치 {autoMatchedCount}개 필드 자동 매핑 + +
+ )} + + {/* --- 카드에 표시된 필드 --- */} + {(cardMappings.length > 0 || unmappedCardFields.length > 0) && ( +
+
+
+ + 카드에 표시된 필드 + +
+
+ + {cardMappings.map(({ entry, index }) => renderMappingRow(entry, index))} + + {/* 카드 필드 중 매핑 안 된 것 -> 칩으로 추가 */} + {unmappedCardFields.length > 0 && ( +
+ {unmappedCardFields.map((f) => ( + + ))} +
+ )} +
+ )} + + {/* --- 추가로 저장할 필드 --- */} + {(extraMappings.length > 0 || availableExtraSourceFields.length > 0 || availableExtraCartFields.length > 0) && ( +
+
+
+ + 추가 저장 필드 + +
+
+ + {extraMappings.map(({ entry, index }) => renderMappingRow(entry, index))} + + {/* 추가 가능한 필드 칩 */} + {(availableExtraSourceFields.length > 0 || availableExtraCartFields.length > 0) && ( +
+ {availableExtraSourceFields.map((col) => ( + + ))} + {availableExtraCartFields.map((f) => ( + + ))} +
+ )} +
+ )} + + )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/index.tsx b/frontend/lib/registry/pop-components/pop-card-list/index.tsx index e78782e2..01b9bf64 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/index.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/index.tsx @@ -60,15 +60,17 @@ PopComponentRegistry.registerComponent({ defaultProps: defaultConfig, connectionMeta: { sendable: [ - { key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 카드의 행 데이터를 전달" }, - { key: "cart_updated", label: "장바구니 상태", type: "event", description: "장바구니 변경 시 count/isDirty 전달" }, - { key: "cart_save_completed", label: "저장 완료", type: "event", description: "장바구니 DB 저장 완료 후 결과 전달" }, - { key: "selected_items", label: "선택된 항목", type: "value", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" }, + { key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" }, + { key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" }, + { key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" }, + { key: "selected_items", label: "선택된 항목", type: "value", category: "data", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" }, + { key: "collected_data", label: "수집 응답", type: "event", category: "event", description: "데이터 수집 요청에 대한 응답 (선택 항목 + 매핑)" }, ], receivable: [ - { key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" }, - { key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" }, - { key: "confirm_trigger", label: "확정 트리거", type: "event", description: "입고 확정 시 수정된 수량 일괄 반영 + 선택 항목 전달" }, + { key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" }, + { key: "cart_save_trigger", label: "저장 요청", type: "event", category: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" }, + { key: "confirm_trigger", label: "확정 트리거", type: "event", category: "event", description: "입고 확정 시 수정된 수량 일괄 반영 + 선택 항목 전달" }, + { key: "collect_data", label: "수집 요청", type: "event", category: "event", description: "버튼에서 데이터+매핑 수집 요청 수신" }, ], }, touchOptimized: true, diff --git a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts index c2baaa55..0f6adda6 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts +++ b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts @@ -33,6 +33,7 @@ export interface ColumnInfo { name: string; type: string; udtName: string; + isPrimaryKey?: boolean; } // ===== SQL 값 이스케이프 ===== @@ -328,6 +329,7 @@ export async function fetchTableColumns( name: col.columnName || col.column_name || col.name, type: col.dataType || col.data_type || col.type || "unknown", udtName: col.dbType || col.udt_name || col.udtName || "unknown", + isPrimaryKey: col.isPrimaryKey === true || col.isPrimaryKey === "true" || col.is_primary_key === true || col.is_primary_key === "true", })); } } diff --git a/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx b/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx index 5fe10ea6..c646dfd6 100644 --- a/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx @@ -21,6 +21,7 @@ import type { PopFieldReadSource, PopFieldAutoGenMapping, } from "./types"; +import type { CollectDataRequest, CollectedDataResponse } from "../types"; import { DEFAULT_FIELD_CONFIG, DEFAULT_SECTION_APPEARANCES } from "./types"; // ======================================== @@ -191,6 +192,35 @@ export function PopFieldComponent({ return unsub; }, [componentId, subscribe, cfg.readSource, fetchReadSourceData]); + // 데이터 수집 요청 수신: 버튼에서 collect_data 요청 → allValues + saveConfig 응답 + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__collect_data`, + (payload: unknown) => { + const request = (payload as Record)?.value as CollectDataRequest | undefined; + + const response: CollectedDataResponse = { + requestId: request?.requestId ?? "", + componentId: componentId, + componentType: "pop-field", + data: { values: allValues }, + mapping: cfg.saveConfig?.tableName + ? { + targetTable: cfg.saveConfig.tableName, + columnMapping: Object.fromEntries( + (cfg.saveConfig.fieldMappings || []).map((m) => [m.fieldId, m.targetColumn]) + ), + } + : null, + }; + + publish(`__comp_output__${componentId}__collected_data`, response); + } + ); + return unsub; + }, [componentId, subscribe, publish, allValues, cfg.saveConfig]); + // 필드 값 변경 핸들러 const handleFieldChange = useCallback( (fieldName: string, value: unknown) => { diff --git a/frontend/lib/registry/pop-components/pop-field/index.tsx b/frontend/lib/registry/pop-components/pop-field/index.tsx index 1c436301..60ed1ba7 100644 --- a/frontend/lib/registry/pop-components/pop-field/index.tsx +++ b/frontend/lib/registry/pop-components/pop-field/index.tsx @@ -66,16 +66,32 @@ PopComponentRegistry.registerComponent({ key: "value_changed", label: "값 변경", type: "value", + category: "data", description: "필드값 변경 시 fieldName + value + allValues 전달", }, + { + key: "collected_data", + label: "수집 응답", + type: "event", + category: "event", + description: "데이터 수집 요청에 대한 응답 (입력값 + 매핑)", + }, ], receivable: [ { key: "set_value", label: "값 설정", type: "value", + category: "data", description: "외부에서 특정 필드 또는 일괄로 값 세팅", }, + { + key: "collect_data", + label: "수집 요청", + type: "event", + category: "event", + description: "버튼에서 데이터+매핑 수집 요청 수신", + }, ], }, touchOptimized: true, diff --git a/frontend/lib/registry/pop-components/pop-search/index.tsx b/frontend/lib/registry/pop-components/pop-search/index.tsx index 87069f38..e78dd11c 100644 --- a/frontend/lib/registry/pop-components/pop-search/index.tsx +++ b/frontend/lib/registry/pop-components/pop-search/index.tsx @@ -36,10 +36,10 @@ PopComponentRegistry.registerComponent({ defaultProps: DEFAULT_SEARCH_CONFIG, connectionMeta: { sendable: [ - { key: "filter_value", label: "필터 값", type: "filter_value", description: "입력한 검색 조건을 다른 컴포넌트에 전달" }, + { key: "filter_value", label: "필터 값", type: "filter_value", category: "filter", description: "입력한 검색 조건을 다른 컴포넌트에 전달" }, ], receivable: [ - { key: "set_value", label: "값 설정", type: "filter_value", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" }, + { key: "set_value", label: "값 설정", type: "filter_value", category: "filter", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" }, ], }, touchOptimized: true, diff --git a/frontend/lib/registry/pop-components/pop-shared/ColumnCombobox.tsx b/frontend/lib/registry/pop-components/pop-shared/ColumnCombobox.tsx new file mode 100644 index 00000000..62d63f02 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-shared/ColumnCombobox.tsx @@ -0,0 +1,105 @@ +"use client"; + +import React, { useState, useMemo } from "react"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import type { ColumnInfo } from "../pop-dashboard/utils/dataFetcher"; + +interface ColumnComboboxProps { + columns: ColumnInfo[]; + value: string; + onSelect: (columnName: string) => void; + placeholder?: string; +} + +export function ColumnCombobox({ + columns, + value, + onSelect, + placeholder = "컬럼을 선택하세요", +}: ColumnComboboxProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + const filtered = useMemo(() => { + if (!search) return columns; + const q = search.toLowerCase(); + return columns.filter((c) => c.name.toLowerCase().includes(q)); + }, [columns, search]); + + return ( + + + + + + + + + + 검색 결과가 없습니다. + + + {filtered.map((col) => ( + { + onSelect(col.name); + setOpen(false); + setSearch(""); + }} + className="text-xs" + > + +
+ {col.name} + + {col.type} + +
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-shared/TableCombobox.tsx b/frontend/lib/registry/pop-components/pop-shared/TableCombobox.tsx new file mode 100644 index 00000000..69b1469e --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-shared/TableCombobox.tsx @@ -0,0 +1,116 @@ +"use client"; + +import React, { useState, useMemo } from "react"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import type { TableInfo } from "../pop-dashboard/utils/dataFetcher"; + +interface TableComboboxProps { + tables: TableInfo[]; + value: string; + onSelect: (tableName: string) => void; + placeholder?: string; +} + +export function TableCombobox({ + tables, + value, + onSelect, + placeholder = "테이블을 선택하세요", +}: TableComboboxProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + const selectedLabel = useMemo(() => { + const found = tables.find((t) => t.tableName === value); + return found ? (found.displayName || found.tableName) : ""; + }, [tables, value]); + + const filtered = useMemo(() => { + if (!search) return tables; + const q = search.toLowerCase(); + return tables.filter( + (t) => + t.tableName.toLowerCase().includes(q) || + (t.displayName && t.displayName.toLowerCase().includes(q)) + ); + }, [tables, search]); + + return ( + + + + + + + + + + 검색 결과가 없습니다. + + + {filtered.map((table) => ( + { + onSelect(table.tableName); + setOpen(false); + setSearch(""); + }} + className="text-xs" + > + +
+ {table.displayName || table.tableName} + {table.displayName && ( + + {table.tableName} + + )} +
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-string-list/index.tsx b/frontend/lib/registry/pop-components/pop-string-list/index.tsx index 4bf6c638..96a6ae97 100644 --- a/frontend/lib/registry/pop-components/pop-string-list/index.tsx +++ b/frontend/lib/registry/pop-components/pop-string-list/index.tsx @@ -35,10 +35,10 @@ PopComponentRegistry.registerComponent({ defaultProps: defaultConfig, connectionMeta: { sendable: [ - { key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 행 데이터를 전달" }, + { key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 행 데이터를 전달" }, ], receivable: [ - { key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 목록 필터링" }, + { key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 목록 필터링" }, ], }, touchOptimized: true, diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index 6aff5126..9dc54978 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -509,7 +509,7 @@ export type CartItemStatus = "in_cart" | "confirmed" | "cancelled"; export interface CartItemWithId extends CartItem { cartId?: string; // DB id (UUID, 저장 후 할당) sourceTable: string; // 원본 테이블명 - rowKey: string; // 원본 행 식별키 (codeField 값) + rowKey: string; // 원본 행 식별키 (keyColumn 값, 기본 id) status: CartItemStatus; _origin: CartItemOrigin; // DB에서 로드 vs 로컬에서 추가 memo?: string; @@ -523,7 +523,7 @@ export type CartSaveMode = "cart" | "direct"; export interface CardCartActionConfig { saveMode: CartSaveMode; // "cart": 장바구니 임시저장, "direct": 대상 테이블 직접 저장 - cartType?: string; // 장바구니 구분값 (예: "purchase_inbound") + keyColumn?: string; // 행 식별 키 컬럼 (기본: "id") label?: string; // 담기 라벨 (기본: "담기") cancelLabel?: string; // 취소 라벨 (기본: "취소") // 하위 호환: 이전 dataFields (사용하지 않지만 기존 데이터 보호) @@ -614,10 +614,80 @@ export interface CartListModeConfig { enabled: boolean; sourceScreenId?: number; sourceComponentId?: string; - cartType?: string; statusFilter?: string; } +// ----- 데이터 수집 패턴 (pop-button ↔ 컴포넌트 간 요청-응답) ----- + +export interface CollectDataRequest { + requestId: string; + action: string; +} + +export interface CollectedDataResponse { + requestId: string; + componentId: string; + componentType: string; + data: { + items?: Record[]; + values?: Record; + }; + mapping?: SaveMapping | null; +} + +export interface SaveMapping { + targetTable: string; + columnMapping: Record; +} + +export interface StatusChangeRule { + targetTable: string; + targetColumn: string; + lookupMode?: "auto" | "manual"; + manualItemField?: string; + manualPkColumn?: string; + valueType: "fixed" | "conditional"; + fixedValue?: string; + conditionalValue?: ConditionalValue; +} + +export interface ConditionalValue { + conditions: StatusCondition[]; + defaultValue?: string; +} + +export interface StatusCondition { + whenColumn: string; + operator: "=" | "!=" | ">" | "<" | ">=" | "<="; + whenValue: string; + thenValue: string; +} + +export interface ExecuteActionPayload { + inserts: { + table: string; + records: Record[]; + }[]; + statusChanges: { + table: string; + column: string; + value: string; + where: Record; + }[]; +} + +// ----- 저장 매핑 (장바구니 -> 대상 테이블) ----- + +export interface CardListSaveMappingEntry { + sourceField: string; + targetColumn: string; +} + +export interface CardListSaveMapping { + targetTable: string; + mappings: CardListSaveMappingEntry[]; +} + // ----- pop-card-list 전체 설정 ----- export interface PopCardListConfig { @@ -637,4 +707,5 @@ export interface PopCardListConfig { cartAction?: CardCartActionConfig; cartListMode?: CartListModeConfig; + saveMapping?: CardListSaveMapping; } From f12fca46bee4a8630135a8dd7bd932d8e9788211 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 3 Mar 2026 16:19:22 +0900 Subject: [PATCH 06/13] =?UTF-8?q?fix(pop):=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=97=AD=EB=B0=A9=ED=96=A5=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20+?= =?UTF-8?q?=20=ED=99=95=EC=A0=95=20=ED=9B=84=EC=86=8D=20=EC=95=A1=EC=85=98?= =?UTF-8?q?=20+=20API=20URL=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useConnectionResolver: _auto 모드에서 역방향(타겟→소스) 라우팅 추가 - pop-button: 입고 확정 성공 후 followUpActions 실행 (navigate/refresh/event) - pop-button: execute-action API URL 경로 수정 (/api/pop/ → /pop/) --- frontend/hooks/pop/useConnectionResolver.ts | 24 +++++++++++++++---- .../registry/pop-components/pop-button.tsx | 22 +++++++++++++++-- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/frontend/hooks/pop/useConnectionResolver.ts b/frontend/hooks/pop/useConnectionResolver.ts index ca408692..14bd321a 100644 --- a/frontend/hooks/pop/useConnectionResolver.ts +++ b/frontend/hooks/pop/useConnectionResolver.ts @@ -11,7 +11,8 @@ * * _auto 모드: * sourceOutput="_auto"인 연결은 소스/타겟의 connectionMeta를 비교하여 - * key가 같고 category="event"인 쌍을 모두 자동 라우팅한다. + * key가 같고 category="event"인 쌍을 양방향으로 자동 라우팅한다. + * (정방향: 소스->타겟, 역방향: 타겟->소스) */ import { useEffect, useRef } from "react"; @@ -85,9 +86,9 @@ export function useConnectionResolver({ if (!sourceType || !targetType) continue; - const pairs = getAutoMatchPairs(sourceType, targetType); - - for (const pair of pairs) { + // 정방향: 소스 sendable -> 타겟 receivable + const forwardPairs = getAutoMatchPairs(sourceType, targetType); + for (const pair of forwardPairs) { const sourceEvent = `__comp_output__${conn.sourceComponent}__${pair.sourceKey}`; const targetEvent = `__comp_input__${conn.targetComponent}__${pair.targetKey}`; @@ -99,6 +100,21 @@ export function useConnectionResolver({ }); unsubscribers.push(unsub); } + + // 역방향: 타겟 sendable -> 소스 receivable + const reversePairs = getAutoMatchPairs(targetType, sourceType); + for (const pair of reversePairs) { + const sourceEvent = `__comp_output__${conn.targetComponent}__${pair.sourceKey}`; + const targetEvent = `__comp_input__${conn.sourceComponent}__${pair.targetKey}`; + + const unsub = subscribe(sourceEvent, (payload: unknown) => { + publish(targetEvent, { + value: payload, + _connectionId: conn.id, + }); + }); + unsubscribers.push(unsub); + } } else { const sourceEvent = `__comp_output__${conn.sourceComponent}__${conn.sourceOutput || conn.sourceField}`; diff --git a/frontend/lib/registry/pop-components/pop-button.tsx b/frontend/lib/registry/pop-components/pop-button.tsx index df9a4fc8..a3d7f4b9 100644 --- a/frontend/lib/registry/pop-components/pop-button.tsx +++ b/frontend/lib/registry/pop-components/pop-button.tsx @@ -547,7 +547,7 @@ export function PopButtonComponent({ const cardListMapping = cardListData?.mapping ?? null; const fieldMapping = fieldData?.mapping ?? null; - const result = await apiClient.post("/api/pop/execute-action", { + const result = await apiClient.post("/pop/execute-action", { action: "inbound-confirm", data: { items: selectedItems, @@ -567,6 +567,24 @@ export function PopButtonComponent({ success: true, count: selectedItems.length, }); + + // 후속 액션 실행 (navigate, refresh 등) + const followUps = config?.followUpActions ?? []; + for (const fa of followUps) { + switch (fa.type) { + case "navigate": + if (fa.targetScreenId) { + publish("__pop_navigate__", { screenId: fa.targetScreenId, params: fa.params }); + } + break; + case "refresh": + publish("__pop_refresh__"); + break; + case "event": + if (fa.eventName) publish(fa.eventName, fa.eventPayload); + break; + } + } } else { toast.error(result.data?.message || "입고 확정에 실패했습니다."); } @@ -577,7 +595,7 @@ export function PopButtonComponent({ setConfirmProcessing(false); setShowInboundConfirm(false); } - }, [componentId, subscribe, publish, config?.statusChangeRules, config?.inboundConfirm?.statusChangeRules]); + }, [componentId, subscribe, publish, config?.statusChangeRules, config?.inboundConfirm?.statusChangeRules, config?.followUpActions]); // 클릭 핸들러 const handleClick = useCallback(async () => { From 2e8300bbf5efba7ed1bc43589d003b3229890a4a Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 3 Mar 2026 17:13:01 +0900 Subject: [PATCH 07/13] =?UTF-8?q?feat(pop):=20=ED=9B=84=EC=86=8D=20?= =?UTF-8?q?=EC=95=A1=EC=85=98=20=ED=99=94=EB=A9=B4=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20+=20=EC=9E=85=EA=B3=A0=ED=99=95=EC=A0=95?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=20=EC=84=A0=ED=83=9D=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=ED=94=BC=EB=93=9C=EB=B0=B1=20-=20PopViewerWithModals?= =?UTF-8?q?=EC=97=90=20=5F=5Fpop=5Fnavigate=5F=5F=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=EB=8F=85=20=EC=B6=94=EA=B0=80=20=20=20-?= =?UTF-8?q?=20targetScreenId=EA=B0=80=20=EC=9E=88=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?=ED=95=B4=EB=8B=B9=20POP=20=ED=99=94=EB=A9=B4=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=9D=B4=EB=8F=99=20=20=20-=20"back"=EC=9D=B4=EB=A9=B4=20ro?= =?UTF-8?q?uter.back(),=20params=EB=8A=94=20=EC=BF=BC=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A7=81=20=EC=A0=84=EB=8B=AC=20-=20=EC=9E=85?= =?UTF-8?q?=EA=B3=A0=ED=99=95=EC=A0=95=20=EB=B2=84=ED=8A=BC=EC=97=90=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=83=81=ED=83=9C=20=EC=8B=9C=EA=B0=81=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=20=20-=20=EB=AF=B8=EC=84=A0=ED=83=9D:=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EC=95=84=EC=9D=B4=EC=BD=98/=EC=83=89?= =?UTF-8?q?=EC=83=81=20=20=20-=20=EC=84=A0=ED=83=9D=EB=90=A8:=20emerald-60?= =?UTF-8?q?0=20=EB=B0=B0=EA=B2=BD=20+=20=EC=84=A0=ED=83=9D=20=EA=B0=9C?= =?UTF-8?q?=EC=88=98=20=EB=B1=83=EC=A7=80=20-=20selected=5Fitems=20connect?= =?UTF-8?q?ionMeta=20category=EB=A5=BC=20"event"=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=ED=95=98=EC=97=AC=20=EC=9E=90=EB=8F=99=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=20=EB=8C=80=EC=83=81=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pop/viewer/PopViewerWithModals.tsx | 23 +++++++++- .../registry/pop-components/pop-button.tsx | 45 ++++++++++++++++++- .../pop-components/pop-card-list/index.tsx | 2 +- 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/frontend/components/pop/viewer/PopViewerWithModals.tsx b/frontend/components/pop/viewer/PopViewerWithModals.tsx index 78d1b647..f322d4c0 100644 --- a/frontend/components/pop/viewer/PopViewerWithModals.tsx +++ b/frontend/components/pop/viewer/PopViewerWithModals.tsx @@ -12,6 +12,7 @@ "use client"; import { useState, useCallback, useEffect, useMemo } from "react"; +import { useRouter } from "next/navigation"; import { Dialog, DialogContent, @@ -61,6 +62,7 @@ export default function PopViewerWithModals({ overrideGap, overridePadding, }: PopViewerWithModalsProps) { + const router = useRouter(); const [modalStack, setModalStack] = useState([]); const { subscribe, publish } = usePopEvent(screenId); @@ -126,11 +128,30 @@ export default function PopViewerWithModals({ }); }); + const unsubNavigate = subscribe("__pop_navigate__", (payload: unknown) => { + const data = payload as { + screenId?: string; + params?: Record; + }; + + if (!data?.screenId) return; + + if (data.screenId === "back") { + router.back(); + } else { + const query = data.params + ? "?" + new URLSearchParams(data.params).toString() + : ""; + window.location.href = `/pop/screens/${data.screenId}${query}`; + } + }); + return () => { unsubOpen(); unsubClose(); + unsubNavigate(); }; - }, [subscribe, publish, layout.modals]); + }, [subscribe, publish, layout.modals, router]); // 최상위 모달만 닫기 (X 버튼, overlay 클릭, ESC) const handleCloseTopModal = useCallback(() => { diff --git a/frontend/lib/registry/pop-components/pop-button.tsx b/frontend/lib/registry/pop-components/pop-button.tsx index a3d7f4b9..0dc5aa50 100644 --- a/frontend/lib/registry/pop-components/pop-button.tsx +++ b/frontend/lib/registry/pop-components/pop-button.tsx @@ -420,6 +420,21 @@ export function PopButtonComponent({ const [showCartConfirm, setShowCartConfirm] = useState(false); const [confirmProcessing, setConfirmProcessing] = useState(false); const [showInboundConfirm, setShowInboundConfirm] = useState(false); + const [inboundSelectedCount, setInboundSelectedCount] = useState(0); + + // 입고 확정 모드: 선택 항목 수 수신 + useEffect(() => { + if (!isInboundConfirmMode || !componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__selected_items`, + (payload: unknown) => { + const data = payload as { value?: unknown[] } | undefined; + const items = Array.isArray(data?.value) ? data.value : []; + setInboundSelectedCount(items.length); + } + ); + return unsub; + }, [isInboundConfirmMode, componentId, subscribe]); // 장바구니 상태 수신 (카드 목록에서 count/isDirty 전달) useEffect(() => { @@ -673,6 +688,20 @@ export function PopButtonComponent({ return ""; }, [isCartMode, cartCount, cartIsDirty]); + // 입고 확정 2상태 아이콘: 미선택(기본 아이콘) / 선택됨(체크 아이콘) + const inboundIconName = useMemo(() => { + if (!isInboundConfirmMode) return iconName; + return inboundSelectedCount > 0 ? (config?.icon || "PackageCheck") : (config?.icon || "PackageCheck"); + }, [isInboundConfirmMode, inboundSelectedCount, config?.icon, iconName]); + + // 입고 확정 2상태 버튼 색상: 미선택(기본) / 선택됨(초록) + const inboundButtonClass = useMemo(() => { + if (!isInboundConfirmMode) return ""; + return inboundSelectedCount > 0 + ? "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600" + : ""; + }, [isInboundConfirmMode, inboundSelectedCount]); + return ( <>
@@ -685,11 +714,12 @@ export function PopButtonComponent({ "transition-transform active:scale-95", isIconOnly && "px-2", cartButtonClass, + inboundButtonClass, )} > - {(isCartMode ? cartIconName : iconName) && ( + {(isCartMode ? cartIconName : isInboundConfirmMode ? inboundIconName : iconName) && ( @@ -711,6 +741,16 @@ export function PopButtonComponent({ {cartCount}
)} + + {/* 입고 확정 선택 개수 배지 */} + {isInboundConfirmMode && inboundSelectedCount > 0 && ( +
+ {inboundSelectedCount} +
+ )}
@@ -1990,6 +2030,7 @@ PopComponentRegistry.registerComponent({ { key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "카드 목록에서 장바구니 변경 시 count/isDirty 수신" }, { key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "카드 목록에서 DB 저장 완료 시 수신" }, { key: "collected_data", label: "수집된 데이터", type: "event", category: "event", description: "컴포넌트에서 수집한 데이터+매핑 응답" }, + { key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "카드 목록에서 체크박스로 선택된 항목 수 수신" }, ], }, touchOptimized: true, diff --git a/frontend/lib/registry/pop-components/pop-card-list/index.tsx b/frontend/lib/registry/pop-components/pop-card-list/index.tsx index 01b9bf64..b9b769af 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/index.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/index.tsx @@ -63,7 +63,7 @@ PopComponentRegistry.registerComponent({ { key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" }, { key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" }, { key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" }, - { key: "selected_items", label: "선택된 항목", type: "value", category: "data", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" }, + { key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" }, { key: "collected_data", label: "수집 응답", type: "event", category: "event", description: "데이터 수집 요청에 대한 응답 (선택 항목 + 매핑)" }, ], receivable: [ From 94a541fc9c4087f6b57c7261d3f9dc12200cdbe8 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 3 Mar 2026 17:46:50 +0900 Subject: [PATCH 08/13] =?UTF-8?q?fix(pop-cart):=20cart=5Ftype=20=EB=B9=88?= =?UTF-8?q?=20=EB=AC=B8=EC=9E=90=EC=97=B4=20=EC=A0=80=EC=9E=A5=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95=20("pop"=20=EB=B3=B5=EC=9B=90)?= =?UTF-8?q?=20-=20cartItemToDbRecord=EC=97=90=EC=84=9C=20cart=5Ftype:=20""?= =?UTF-8?q?=20->=20"pop"=EC=9C=BC=EB=A1=9C=20=EB=B3=B5=EC=9B=90=20-=20load?= =?UTF-8?q?FromDb=20=ED=95=84=ED=84=B0=EC=97=90=20cart=5Ftype:=20"pop"=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20POP?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A7=8C=20=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?-=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81(9aaf0575)=20=EC=A4=91?= =?UTF-8?q?=20=EB=88=84=EB=9D=BD=EB=90=9C=20=EA=B0=92=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/hooks/pop/useCartSync.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/hooks/pop/useCartSync.ts b/frontend/hooks/pop/useCartSync.ts index 873ce5da..8060f67d 100644 --- a/frontend/hooks/pop/useCartSync.ts +++ b/frontend/hooks/pop/useCartSync.ts @@ -109,7 +109,7 @@ function cartItemToDbRecord( : item.row; return { - cart_type: "", + cart_type: "pop", screen_id: screenId, source_table: item.sourceTable, row_key: item.rowKey, @@ -162,6 +162,7 @@ export function useCartSync( size: 500, filters: { screen_id: screenId, + cart_type: "pop", status: "in_cart", }, }); From 6c9e35e8b2c9ba818fa77d51600a708a2ea600fa Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 3 Mar 2026 18:57:59 +0900 Subject: [PATCH 09/13] =?UTF-8?q?fix:=20POP=20=EB=B7=B0=EC=96=B4=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=ED=95=98=EB=8B=A8=20=ED=9A=8C=EC=83=89=20?= =?UTF-8?q?=EC=98=81=EC=97=AD=20=EC=A0=9C=EA=B1=B0=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88(overflow-auto)?= =?UTF-8?q?=EC=97=90=20=EB=B0=B0=EA=B2=BD=EC=83=89=EC=9D=B4=20=EC=97=86?= =?UTF-8?q?=EC=96=B4=20=EB=B6=80=EB=AA=A8=EC=9D=98=20bg-gray-100=EC=9D=B4?= =?UTF-8?q?=20=EC=BB=A8=ED=85=90=EC=B8=A0=20=ED=95=98=EB=8B=A8=20=EB=B9=88?= =?UTF-8?q?=20=EA=B3=B5=EA=B0=84=EC=97=90=20=EB=85=B8=EC=B6=9C=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95.=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=BB=A8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=84=88=EC=97=90=20=EC=A7=81=EC=A0=91=20bg-white=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=ED=95=98=EC=97=AC=20=EC=9E=90=EC=8B=9D=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=20=EA=B3=84=EC=82=B0=EA=B3=BC=20=EB=AC=B4?= =?UTF-8?q?=EA=B4=80=ED=95=98=EA=B2=8C=20=ED=9D=B0=EC=83=89=20=EB=B0=B0?= =?UTF-8?q?=EA=B2=BD=EC=9D=B4=20=EC=9C=A0=EC=A7=80=EB=90=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD.=20=ED=94=84=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=AA=A8=EB=93=9C=EC=97=90=EC=84=9C=EB=8A=94=20=EB=94=94?= =?UTF-8?q?=EB=B0=94=EC=9D=B4=EC=8A=A4=20=ED=94=84=EB=A0=88=EC=9E=84=20?= =?UTF-8?q?=ED=9A=A8=EA=B3=BC=EB=A5=BC=20=EC=9C=84=ED=95=B4=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=ED=9A=8C=EC=83=89=20=EB=B0=B0=EA=B2=BD=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/(pop)/pop/screens/[screenId]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index 861795b5..3d49aeb9 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -285,7 +285,7 @@ function PopScreenViewPage() { )} {/* POP 화면 컨텐츠 */} -
+
{/* 현재 모드 표시 (일반 모드) */} {!isPreviewMode && (
From ce5c2426b500d6f2f79f33161c3cd023295a5847 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 4 Mar 2026 11:41:31 +0900 Subject: [PATCH 10/13] =?UTF-8?q?feat(pop):=20POP=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=B3=B5=EC=82=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(=EB=8B=A8=EC=9D=BC=20=ED=99=94=EB=A9=B4=20+=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=9D=BC=EA=B4=84=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC)=20=EC=B5=9C=EA=B3=A0=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=EC=9D=98=20POP=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B8=20=ED=9A=8C=EC=82=AC=EB=A1=9C=20=EB=B3=B5=EC=82=AC?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80.?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EB=8B=A8=EC=9C=84=20=EB=B3=B5=EC=82=AC?= =?UTF-8?q?=EC=99=80=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC(=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9)=20=EB=8B=A8=EC=9C=84=20=EC=9D=BC=EA=B4=84=20?= =?UTF-8?q?=EB=B3=B5=EC=82=AC=EB=A5=BC=20=EB=AA=A8=EB=91=90=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=ED=95=98=EB=A9=B0,=20=ED=99=94=EB=A9=B4=20=EA=B0=84?= =?UTF-8?q?=20=EC=B0=B8=EC=A1=B0(cartScreenId,=20sourceScreenId=20?= =?UTF-8?q?=EB=93=B1)=EB=A5=BC=20=EC=9E=90=EB=8F=99=20=EC=B9=98=ED=99=98?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EA=B9=8C=EC=A7=80=20=EB=8C=80=EC=83=81=20?= =?UTF-8?q?=ED=9A=8C=EC=82=AC=EC=97=90=20=EC=9E=AC=EC=83=9D=EC=84=B1?= =?UTF-8?q?=ED=95=9C=EB=8B=A4.=20[=EB=B0=B1=EC=97=94=EB=93=9C]=20-=20analy?= =?UTF-8?q?zePopScreenLinks:=20POP=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83?= =?UTF-8?q?=20=EB=82=B4=20=EB=8B=A4=EB=A5=B8=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=20=EC=8A=A4=EC=BA=94=20-=20deployPopScreens:?= =?UTF-8?q?=20screen=5Fdefinitions=20+=20screen=5Flayouts=5Fpop=20?= =?UTF-8?q?=EB=B3=B5=EC=82=AC,=20=20=20screenId=20=EC=B0=B8=EC=A1=B0=20?= =?UTF-8?q?=EC=B9=98=ED=99=98,=20numberingRuleId=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94,=20=EA=B7=B8=EB=A3=B9=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EB=B3=B5=EC=82=AC=20-=20POP=20=EA=B7=B8=EB=A3=B9=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=BF=BC=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20(screen?= =?UTF-8?q?=5Flayouts=5Fpop=20JOIN=EC=9C=BC=EB=A1=9C=20=EC=8B=A4=EC=A0=9C?= =?UTF-8?q?=20POP=20=ED=99=94=EB=A9=B4=EB=A7=8C=20=EC=B9=B4=EC=9A=B4?= =?UTF-8?q?=ED=8A=B8)=20-=20ensurePopRootGroup=20=EC=B5=9C=EA=B3=A0?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=A0=84=EC=9A=A9=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20[=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EC=97=94=EB=93=9C]=20-=20PopDeployModal:=20=EB=8B=A8=EC=9D=BC?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4/=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=84=20=EB=B3=B5=EC=82=AC=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?(=EB=8C=80=EC=83=81=20=ED=9A=8C=EC=82=AC=20=EC=84=A0=ED=83=9D,?= =?UTF-8?q?=20=20=20=EC=97=B0=EA=B2=B0=20=ED=99=94=EB=A9=B4=20=EA=B0=90?= =?UTF-8?q?=EC=A7=80,=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0)=20-=20PopCate?= =?UTF-8?q?goryTree:=20=EA=B7=B8=EB=A3=B9=20=EC=BB=A8=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=A9=94=EB=89=B4=EC=97=90=20'=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EB=B3=B5=EC=82=AC'=20=EC=B6=94=EA=B0=80,?= =?UTF-8?q?=20=20=20=ED=95=98=EC=9C=84=20=EA=B7=B8=EB=A3=B9=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EA=B9=8C=EC=A7=80=20=EC=9E=AC=EA=B7=80=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=20-=20PopScreenSettingModal:=20UI=20=EA=B0=84?= =?UTF-8?q?=EC=86=8C=ED=99=94=20=EB=B0=8F=20=ED=99=94=EB=A9=B4=EB=AA=85=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20=EB=B3=B4=EC=99=84=20?= =?UTF-8?q?-=20screenApi:=20analyzePopScreenLinks,=20deployPopScreens=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/screenGroupController.ts | 70 ++- .../controllers/screenManagementController.ts | 79 +++ .../src/routes/screenManagementRoutes.ts | 6 + .../src/services/screenManagementService.ts | 516 +++++++++++++++- .../admin/screenMng/popScreenMngList/page.tsx | 51 ++ .../pop/management/PopCategoryTree.tsx | 226 +++++-- .../pop/management/PopDeployModal.tsx | 560 ++++++++++++++++++ .../pop/management/PopScreenSettingModal.tsx | 101 ++-- frontend/components/pop/management/index.ts | 2 + frontend/lib/api/screen.ts | 53 ++ 10 files changed, 1539 insertions(+), 125 deletions(-) create mode 100644 frontend/components/pop/management/PopDeployModal.tsx diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index b53454b9..df975d8f 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -2574,11 +2574,11 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons const companyCode = req.user?.companyCode || "*"; const { searchTerm } = req.query; - let whereClause = "WHERE hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP'"; + let whereClause = "WHERE (hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP')"; const params: any[] = []; let paramIndex = 1; - // 회사 코드 필터링 (멀티테넌시) + // 회사 코드 필터링 (멀티테넌시) - 일반 회사는 자기 회사 데이터만 if (companyCode !== "*") { whereClause += ` AND company_code = $${paramIndex}`; params.push(companyCode); @@ -2592,11 +2592,13 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons paramIndex++; } - // POP 그룹 조회 (계층 구조를 위해 전체 조회) + // POP 그룹 조회 (POP 레이아웃이 있는 화면만 카운트/포함) const dataQuery = ` SELECT sg.*, - (SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count, + (SELECT COUNT(*) FROM screen_group_screens sgs + INNER JOIN screen_layouts_pop slp ON sgs.screen_id = slp.screen_id AND sgs.company_code = slp.company_code + WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code) as screen_count, (SELECT json_agg( json_build_object( 'id', sgs.id, @@ -2609,7 +2611,8 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons ) ORDER BY sgs.display_order ) FROM screen_group_screens sgs LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id - WHERE sgs.group_id = sg.id + INNER JOIN screen_layouts_pop slp ON sgs.screen_id = slp.screen_id AND sgs.company_code = slp.company_code + WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code ) as screens FROM screen_groups sg ${whereClause} @@ -2768,6 +2771,14 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo const existing = await pool.query(checkQuery, checkParams); if (existing.rows.length === 0) { + // 그룹이 다른 회사 소속인지 확인하여 구체적 메시지 제공 + const anyGroup = await pool.query(`SELECT company_code FROM screen_groups WHERE id = $1`, [id]); + if (anyGroup.rows.length > 0) { + return res.status(403).json({ + success: false, + message: `이 그룹은 ${anyGroup.rows[0].company_code === "*" ? "최고관리자" : anyGroup.rows[0].company_code} 소속이라 삭제할 수 없습니다.` + }); + } return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." }); } @@ -2782,7 +2793,10 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo [id] ); if (parseInt(childCheck.rows[0].count) > 0) { - return res.status(400).json({ success: false, message: "하위 그룹이 있어 삭제할 수 없습니다." }); + return res.status(400).json({ + success: false, + message: `하위 그룹이 ${childCheck.rows[0].count}개 있어 삭제할 수 없습니다. 하위 그룹을 먼저 삭제해주세요.` + }); } // 연결된 화면 확인 @@ -2791,7 +2805,10 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo [id] ); if (parseInt(screenCheck.rows[0].count) > 0) { - return res.status(400).json({ success: false, message: "그룹에 연결된 화면이 있어 삭제할 수 없습니다." }); + return res.status(400).json({ + success: false, + message: `그룹에 연결된 화면이 ${screenCheck.rows[0].count}개 있어 삭제할 수 없습니다. 화면을 먼저 제거해주세요.` + }); } // 삭제 @@ -2806,33 +2823,44 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo } }; -// POP 루트 그룹 확보 (없으면 자동 생성) +// POP 루트 그룹 확보 (최고관리자 전용 - 일반 회사는 복사 기능으로 배포) export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Response) => { try { const companyCode = req.user?.companyCode || "*"; - // POP 루트 그룹 확인 - const checkQuery = ` - SELECT * FROM screen_groups - WHERE hierarchy_path = 'POP' AND company_code = $1 - `; - const existing = await pool.query(checkQuery, [companyCode]); - - if (existing.rows.length > 0) { - return res.json({ success: true, data: existing.rows[0], message: "POP 루트 그룹이 이미 존재합니다." }); + // 최고관리자만 자동 생성 + if (companyCode !== "*") { + const existing = await pool.query( + `SELECT * FROM screen_groups WHERE hierarchy_path = 'POP' AND company_code = $1`, + [companyCode] + ); + if (existing.rows.length > 0) { + return res.json({ success: true, data: existing.rows[0] }); + } + return res.json({ success: true, data: null, message: "POP 루트 그룹이 없습니다." }); + } + + // 최고관리자(*): 루트 그룹 확인 후 없으면 생성 + const checkQuery = ` + SELECT * FROM screen_groups + WHERE hierarchy_path = 'POP' AND company_code = '*' + `; + const existing = await pool.query(checkQuery, []); + + if (existing.rows.length > 0) { + return res.json({ success: true, data: existing.rows[0] }); } - // 없으면 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤) const insertQuery = ` INSERT INTO screen_groups ( group_name, group_code, hierarchy_path, company_code, description, display_order, is_active, writer - ) VALUES ('POP 화면', 'POP', 'POP', $1, 'POP 화면 관리 루트', 0, 'Y', $2) + ) VALUES ('POP 화면', 'POP', 'POP', '*', 'POP 화면 관리 루트', 0, 'Y', $1) RETURNING * `; - const result = await pool.query(insertQuery, [companyCode, req.user?.userId || ""]); + const result = await pool.query(insertQuery, [req.user?.userId || ""]); - logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id, companyCode }); + logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id }); res.json({ success: true, data: result.rows[0], message: "POP 루트 그룹이 생성되었습니다." }); } catch (error: any) { diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index 53ff1b96..afd03461 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -1237,3 +1237,82 @@ export const copyCascadingRelation = async ( }); } }; + +// POP 화면 연결 분석 +export const analyzePopScreenLinks = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + + const result = await screenManagementService.analyzePopScreenLinks( + parseInt(screenId), + companyCode, + ); + + res.json({ success: true, data: result }); + } catch (error: any) { + console.error("POP 화면 연결 분석 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "POP 화면 연결 분석에 실패했습니다.", + }); + } +}; + +// POP 화면 배포 (다른 회사로 복사) +export const deployPopScreens = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { screens, targetCompanyCode, groupStructure } = req.body; + const { companyCode, userId } = req.user as any; + + if (!screens || !Array.isArray(screens) || screens.length === 0) { + res.status(400).json({ + success: false, + message: "배포할 화면 목록이 필요합니다.", + }); + return; + } + + if (!targetCompanyCode) { + res.status(400).json({ + success: false, + message: "대상 회사 코드가 필요합니다.", + }); + return; + } + + if (companyCode !== "*") { + res.status(403).json({ + success: false, + message: "최고 관리자만 POP 화면을 배포할 수 있습니다.", + }); + return; + } + + const result = await screenManagementService.deployPopScreens({ + screens, + groupStructure: groupStructure || undefined, + targetCompanyCode, + companyCode, + userId, + }); + + res.json({ + success: true, + data: result, + message: `POP 화면 ${result.deployedScreens.length}개가 ${targetCompanyCode}에 배포되었습니다.`, + }); + } catch (error: any) { + console.error("POP 화면 배포 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "POP 화면 배포에 실패했습니다.", + }); + } +}; diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index 456a74a0..767cb912 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -42,6 +42,8 @@ import { copyCategoryMapping, copyTableTypeColumns, copyCascadingRelation, + analyzePopScreenLinks, + deployPopScreens, } from "../controllers/screenManagementController"; const router = express.Router(); @@ -123,4 +125,8 @@ router.post("/copy-table-type-columns", copyTableTypeColumns); // 연쇄관계 설정 복제 router.post("/copy-cascading-relation", copyCascadingRelation); +// POP 화면 배포 (다른 회사로 복사) +router.get("/screens/:screenId/pop-links", analyzePopScreenLinks); +router.post("/deploy-pop-screens", deployPopScreens); + export default router; diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 77c82a91..0cf2da7e 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -5425,28 +5425,24 @@ export class ScreenManagementService { async getScreenIdsWithPopLayout( companyCode: string, ): Promise { - console.log(`=== POP 레이아웃 존재 화면 ID 조회 ===`); - console.log(`회사 코드: ${companyCode}`); - let result: { screen_id: number }[]; if (companyCode === "*") { - // 최고 관리자: 모든 POP 레이아웃 조회 result = await query<{ screen_id: number }>( `SELECT DISTINCT screen_id FROM screen_layouts_pop`, [], ); } else { - // 일반 회사: 해당 회사 또는 공통(*) 레이아웃 조회 + // 일반 회사: 해당 회사 레이아웃만 조회 (company_code='*'는 최고관리자 전용) result = await query<{ screen_id: number }>( `SELECT DISTINCT screen_id FROM screen_layouts_pop - WHERE company_code = $1 OR company_code = '*'`, + WHERE company_code = $1`, [companyCode], ); } const screenIds = result.map((r) => r.screen_id); - console.log(`POP 레이아웃 존재 화면 수: ${screenIds.length}개`); + logger.info("POP 레이아웃 존재 화면 ID 조회", { companyCode, count: screenIds.length }); return screenIds; } @@ -5484,6 +5480,512 @@ export class ScreenManagementService { console.log(`POP 레이아웃 삭제 완료`); return true; } + + // ============================================================ + // POP 화면 배포 (다른 회사로 복사) + // ============================================================ + + /** + * POP layout_data 내 다른 화면 참조를 스캔하여 연결 관계 분석 + */ + async analyzePopScreenLinks( + screenId: number, + companyCode: string, + ): Promise<{ + linkedScreenIds: number[]; + references: Array<{ + componentId: string; + referenceType: string; + targetScreenId: number; + }>; + }> { + const layoutResult = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = $2`, + [screenId, companyCode], + ); + + if (!layoutResult?.layout_data) { + return { linkedScreenIds: [], references: [] }; + } + + const layoutData = layoutResult.layout_data; + const references: Array<{ + componentId: string; + referenceType: string; + targetScreenId: number; + }> = []; + + const scanComponents = (components: Record) => { + for (const [compId, comp] of Object.entries(components)) { + const config = (comp as any).config || {}; + + if (config.cart?.cartScreenId) { + const sid = parseInt(config.cart.cartScreenId); + if (!isNaN(sid) && sid !== screenId) { + references.push({ + componentId: compId, + referenceType: "cartScreenId", + targetScreenId: sid, + }); + } + } + + if (config.cartListMode?.sourceScreenId) { + const sid = + typeof config.cartListMode.sourceScreenId === "number" + ? config.cartListMode.sourceScreenId + : parseInt(config.cartListMode.sourceScreenId); + if (!isNaN(sid) && sid !== screenId) { + references.push({ + componentId: compId, + referenceType: "sourceScreenId", + targetScreenId: sid, + }); + } + } + + if (Array.isArray(config.followUpActions)) { + for (const action of config.followUpActions) { + if (action.targetScreenId) { + const sid = parseInt(action.targetScreenId); + if (!isNaN(sid) && sid !== screenId) { + references.push({ + componentId: compId, + referenceType: "targetScreenId", + targetScreenId: sid, + }); + } + } + } + } + + if (config.action?.modalScreenId) { + const sid = parseInt(config.action.modalScreenId); + if (!isNaN(sid) && sid !== screenId) { + references.push({ + componentId: compId, + referenceType: "modalScreenId", + targetScreenId: sid, + }); + } + } + } + }; + + if (layoutData.components) { + scanComponents(layoutData.components); + } + + if (Array.isArray(layoutData.modals)) { + for (const modal of layoutData.modals) { + if (modal.components) { + scanComponents(modal.components); + } + } + } + + const linkedScreenIds = [ + ...new Set(references.map((r) => r.targetScreenId)), + ]; + + return { linkedScreenIds, references }; + } + + /** + * POP 화면 배포 (최고관리자 화면을 특정 회사로 복사) + * - screen_definitions + screen_layouts_pop 복사 + * - 화면 간 참조(cartScreenId, sourceScreenId 등) 자동 치환 + * - numberingRuleId 초기화 + */ + async deployPopScreens(data: { + screens: Array<{ + sourceScreenId: number; + screenName: string; + screenCode: string; + }>; + groupStructure?: { + sourceGroupId: number; + groupName: string; + groupCode: string; + children?: Array<{ + sourceGroupId: number; + groupName: string; + groupCode: string; + screenIds: number[]; + }>; + screenIds: number[]; + }; + targetCompanyCode: string; + companyCode: string; + userId: string; + }): Promise<{ + deployedScreens: Array<{ + sourceScreenId: number; + newScreenId: number; + screenName: string; + screenCode: string; + }>; + createdGroups?: number; + }> { + if (data.companyCode !== "*") { + throw new Error("최고 관리자만 POP 화면을 배포할 수 있습니다."); + } + + return await transaction(async (client) => { + const screenIdMap = new Map(); + const deployedScreens: Array<{ + sourceScreenId: number; + newScreenId: number; + screenName: string; + screenCode: string; + }> = []; + + // 1단계: screen_definitions 복사 + for (const screen of data.screens) { + const sourceResult = await client.query( + `SELECT * FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screen.sourceScreenId], + ); + + if (sourceResult.rows.length === 0) { + throw new Error( + `원본 화면(ID: ${screen.sourceScreenId})을 찾을 수 없습니다.`, + ); + } + + const sourceScreen = sourceResult.rows[0]; + + const existingResult = await client.query( + `SELECT screen_id FROM screen_definitions + WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL + LIMIT 1`, + [screen.screenCode, data.targetCompanyCode], + ); + + if (existingResult.rows.length > 0) { + throw new Error( + `화면 코드 "${screen.screenCode}"가 대상 회사에 이미 존재합니다.`, + ); + } + + const newScreenResult = await client.query( + `INSERT INTO screen_definitions ( + screen_code, screen_name, description, company_code, table_name, + is_active, created_by, created_date, updated_by, updated_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $7, NOW()) + RETURNING *`, + [ + screen.screenCode, + screen.screenName, + sourceScreen.description, + data.targetCompanyCode, + sourceScreen.table_name, + "Y", + data.userId, + ], + ); + + const newScreen = newScreenResult.rows[0]; + screenIdMap.set(screen.sourceScreenId, newScreen.screen_id); + + deployedScreens.push({ + sourceScreenId: screen.sourceScreenId, + newScreenId: newScreen.screen_id, + screenName: screen.screenName, + screenCode: screen.screenCode, + }); + + logger.info("POP 화면 배포 - screen_definitions 생성", { + sourceScreenId: screen.sourceScreenId, + newScreenId: newScreen.screen_id, + targetCompanyCode: data.targetCompanyCode, + }); + } + + // 2단계: screen_layouts_pop 복사 + 참조 치환 + for (const screen of data.screens) { + const newScreenId = screenIdMap.get(screen.sourceScreenId); + if (!newScreenId) continue; + + // 원본 POP 레이아웃 조회 (company_code = '*' 우선, fallback) + let layoutResult = await client.query<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = '*'`, + [screen.sourceScreenId], + ); + + let layoutData = layoutResult.rows[0]?.layout_data; + if (!layoutData) { + const fallbackResult = await client.query<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 LIMIT 1`, + [screen.sourceScreenId], + ); + layoutData = fallbackResult.rows[0]?.layout_data; + } + + if (!layoutData) { + logger.warn("POP 레이아웃 없음, 건너뜀", { + sourceScreenId: screen.sourceScreenId, + }); + continue; + } + + const updatedLayoutData = this.updatePopLayoutScreenReferences( + JSON.parse(JSON.stringify(layoutData)), + screenIdMap, + ); + + await client.query( + `INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by) + VALUES ($1, $2, $3, NOW(), NOW(), $4, $4) + ON CONFLICT (screen_id, company_code) + DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = $4`, + [ + newScreenId, + data.targetCompanyCode, + JSON.stringify(updatedLayoutData), + data.userId, + ], + ); + + logger.info("POP 레이아웃 복사 완료", { + sourceScreenId: screen.sourceScreenId, + newScreenId, + componentCount: Object.keys(updatedLayoutData.components || {}) + .length, + }); + } + + // 3단계: 그룹 구조 복사 (groupStructure가 있는 경우) + let createdGroups = 0; + if (data.groupStructure) { + const gs = data.groupStructure; + + // 대상 회사의 POP 루트 그룹 찾기/생성 + let popRootResult = await client.query( + `SELECT id FROM screen_groups + WHERE hierarchy_path = 'POP' AND company_code = $1 LIMIT 1`, + [data.targetCompanyCode], + ); + + let popRootId: number; + if (popRootResult.rows.length > 0) { + popRootId = popRootResult.rows[0].id; + } else { + const createRootResult = await client.query( + `INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, writer, is_active, display_order) + VALUES ('POP 화면', 'POP_ROOT', 'POP', $1, $2, 'Y', 0) RETURNING id`, + [data.targetCompanyCode, data.userId], + ); + popRootId = createRootResult.rows[0].id; + } + + // 메인 그룹 생성 (중복 코드 방지: _COPY 접미사 추가) + const mainGroupCode = gs.groupCode + "_COPY"; + const dupCheck = await client.query( + `SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`, + [mainGroupCode, data.targetCompanyCode], + ); + + let mainGroupId: number; + if (dupCheck.rows.length > 0) { + mainGroupId = dupCheck.rows[0].id; + } else { + const mainGroupResult = await client.query( + `INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, parent_group_id, writer, is_active, display_order) + VALUES ($1, $2, $3, $4, $5, $6, 'Y', 0) RETURNING id`, + [ + gs.groupName, + mainGroupCode, + `POP/${mainGroupCode}`, + data.targetCompanyCode, + popRootId, + data.userId, + ], + ); + mainGroupId = mainGroupResult.rows[0].id; + createdGroups++; + } + + // 메인 그룹에 화면 연결 + for (const oldScreenId of gs.screenIds) { + const newScreenId = screenIdMap.get(oldScreenId); + if (!newScreenId) continue; + await client.query( + `INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code) + VALUES ($1, $2, 'main', 0, 'N', $3) + ON CONFLICT DO NOTHING`, + [mainGroupId, newScreenId, data.targetCompanyCode], + ); + } + + // 하위 그룹 생성 + 화면 연결 + if (gs.children) { + for (let i = 0; i < gs.children.length; i++) { + const child = gs.children[i]; + const childGroupCode = child.groupCode + "_COPY"; + + const childDupCheck = await client.query( + `SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`, + [childGroupCode, data.targetCompanyCode], + ); + + let childGroupId: number; + if (childDupCheck.rows.length > 0) { + childGroupId = childDupCheck.rows[0].id; + } else { + const childResult = await client.query( + `INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, parent_group_id, writer, is_active, display_order) + VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7) RETURNING id`, + [ + child.groupName, + childGroupCode, + `POP/${mainGroupCode}/${childGroupCode}`, + data.targetCompanyCode, + mainGroupId, + data.userId, + i, + ], + ); + childGroupId = childResult.rows[0].id; + createdGroups++; + } + + for (const oldScreenId of child.screenIds) { + const newScreenId = screenIdMap.get(oldScreenId); + if (!newScreenId) continue; + await client.query( + `INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code) + VALUES ($1, $2, 'main', 0, 'N', $3) + ON CONFLICT DO NOTHING`, + [childGroupId, newScreenId, data.targetCompanyCode], + ); + } + } + } + + logger.info("POP 그룹 구조 복사 완료", { + targetCompanyCode: data.targetCompanyCode, + createdGroups, + mainGroupName: gs.groupName, + }); + } + + return { deployedScreens, createdGroups }; + }); + } + + /** + * POP layout_data 내 screen_id 참조 치환 + * componentId, connectionId는 레이아웃 내부 식별자이므로 변경 불필요 + */ + private updatePopLayoutScreenReferences( + layoutData: any, + screenIdMap: Map, + ): any { + if (!layoutData?.components) return layoutData; + + const updateComponents = ( + components: Record, + ): Record => { + const updated: Record = {}; + + for (const [compId, comp] of Object.entries(components)) { + const updatedComp = JSON.parse(JSON.stringify(comp)); + const config = updatedComp.config || {}; + + // cart.cartScreenId (string) + if (config.cart?.cartScreenId) { + const oldId = parseInt(config.cart.cartScreenId); + const newId = screenIdMap.get(oldId); + if (newId) { + config.cart.cartScreenId = String(newId); + logger.info(`POP 참조 치환: cartScreenId ${oldId} -> ${newId}`); + } + } + + // cartListMode.sourceScreenId (number) + if (config.cartListMode?.sourceScreenId) { + const oldId = + typeof config.cartListMode.sourceScreenId === "number" + ? config.cartListMode.sourceScreenId + : parseInt(config.cartListMode.sourceScreenId); + const newId = screenIdMap.get(oldId); + if (newId) { + config.cartListMode.sourceScreenId = newId; + logger.info( + `POP 참조 치환: sourceScreenId ${oldId} -> ${newId}`, + ); + } + } + + // followUpActions[].targetScreenId (string) + if (Array.isArray(config.followUpActions)) { + for (const action of config.followUpActions) { + if (action.targetScreenId) { + const oldId = parseInt(action.targetScreenId); + const newId = screenIdMap.get(oldId); + if (newId) { + action.targetScreenId = String(newId); + logger.info( + `POP 참조 치환: targetScreenId ${oldId} -> ${newId}`, + ); + } + } + } + } + + // action.modalScreenId (숫자형이면 화면 참조로 간주) + if (config.action?.modalScreenId) { + const oldId = parseInt(config.action.modalScreenId); + if (!isNaN(oldId)) { + const newId = screenIdMap.get(oldId); + if (newId) { + config.action.modalScreenId = String(newId); + logger.info( + `POP 참조 치환: modalScreenId ${oldId} -> ${newId}`, + ); + } + } + } + + // numberingRuleId 초기화 (배포 후 대상 회사에서 재설정 필요) + if (config.numberingRuleId) { + logger.info(`POP 채번규칙 초기화: ${config.numberingRuleId}`); + config.numberingRuleId = ""; + } + if (config.autoGenMappings) { + for (const mapping of Object.values(config.autoGenMappings) as any[]) { + if (mapping?.numberingRuleId) { + logger.info( + `POP 채번규칙 초기화: ${mapping.numberingRuleId}`, + ); + mapping.numberingRuleId = ""; + } + } + } + + updatedComp.config = config; + updated[compId] = updatedComp; + } + + return updated; + }; + + layoutData.components = updateComponents(layoutData.components); + + if (Array.isArray(layoutData.modals)) { + for (const modal of layoutData.modals) { + if (modal.components) { + modal.components = updateComponents(modal.components); + } + } + } + + return layoutData; + } } // 서비스 인스턴스 export diff --git a/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx index d9e289ca..d8c10cf6 100644 --- a/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx @@ -13,6 +13,7 @@ import { Settings, LayoutGrid, GitBranch, + Upload, } from "lucide-react"; import { PopDesigner } from "@/components/pop/designer"; import { ScrollToTop } from "@/components/common/ScrollToTop"; @@ -27,6 +28,7 @@ import { PopScreenPreview, PopScreenFlowView, PopScreenSettingModal, + PopDeployModal, } from "@/components/pop/management"; import { PopScreenGroup } from "@/lib/api/popScreenGroup"; @@ -62,6 +64,10 @@ export default function PopScreenManagementPage() { // UI 상태 const [isCreateOpen, setIsCreateOpen] = useState(false); const [isSettingModalOpen, setIsSettingModalOpen] = useState(false); + const [isDeployModalOpen, setIsDeployModalOpen] = useState(false); + const [deployGroupScreens, setDeployGroupScreens] = useState([]); + const [deployGroupName, setDeployGroupName] = useState(""); + const [deployGroupInfo, setDeployGroupInfo] = useState(undefined); const [devicePreview, setDevicePreview] = useState("tablet"); const [rightPanelView, setRightPanelView] = useState("preview"); @@ -235,6 +241,21 @@ export default function PopScreenManagementPage() { + {selectedScreen && ( + + )}
diff --git a/frontend/components/pop/management/PopCategoryTree.tsx b/frontend/components/pop/management/PopCategoryTree.tsx index 0689d699..3ad767e1 100644 --- a/frontend/components/pop/management/PopCategoryTree.tsx +++ b/frontend/components/pop/management/PopCategoryTree.tsx @@ -20,6 +20,8 @@ import { ArrowUp, ArrowDown, Search, + Settings, + Copy, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -68,11 +70,27 @@ import { // 타입 정의 // ============================================================ +export interface GroupCopyInfo { + sourceGroupId: number; + groupName: string; + groupCode: string; + screenIds: number[]; + children: Array<{ + sourceGroupId: number; + groupName: string; + groupCode: string; + screenIds: number[]; + }>; +} + interface PopCategoryTreeProps { screens: ScreenDefinition[]; // POP 레이아웃이 있는 화면 목록 selectedScreen: ScreenDefinition | null; onScreenSelect: (screen: ScreenDefinition) => void; onScreenDesign: (screen: ScreenDefinition) => void; + onScreenSettings?: (screen: ScreenDefinition) => void; + onScreenCopy?: (screen: ScreenDefinition) => void; + onGroupCopy?: (screens: ScreenDefinition[], groupName: string, groupInfo?: GroupCopyInfo) => void; onGroupSelect?: (group: PopScreenGroup | null) => void; searchTerm?: string; } @@ -87,6 +105,8 @@ interface TreeNodeProps { onGroupSelect: (group: PopScreenGroup) => void; onScreenSelect: (screen: ScreenDefinition) => void; onScreenDesign: (screen: ScreenDefinition) => void; + onScreenSettings: (screen: ScreenDefinition) => void; + onScreenCopy: (screen: ScreenDefinition) => void; onEditGroup: (group: PopScreenGroup) => void; onDeleteGroup: (group: PopScreenGroup) => void; onAddSubGroup: (parentGroup: PopScreenGroup) => void; @@ -101,6 +121,7 @@ interface TreeNodeProps { onMoveScreenUp: (screen: ScreenDefinition, groupId: number) => void; onMoveScreenDown: (screen: ScreenDefinition, groupId: number) => void; onDeleteScreen: (screen: ScreenDefinition) => void; + onGroupCopy: (screens: ScreenDefinition[], groupName: string, groupInfo?: GroupCopyInfo) => void; } // ============================================================ @@ -118,6 +139,7 @@ function TreeNode({ onMoveScreenUp, onMoveScreenDown, onDeleteScreen, + onGroupCopy, expandedGroups, onToggle, selectedGroupId, @@ -125,6 +147,8 @@ function TreeNode({ onGroupSelect, onScreenSelect, onScreenDesign, + onScreenSettings, + onScreenCopy, onEditGroup, onDeleteGroup, onAddSubGroup, @@ -134,7 +158,7 @@ function TreeNode({ const hasChildren = (group.children && group.children.length > 0) || (group.screens && group.screens.length > 0); const isSelected = selectedGroupId === group.id; - // 그룹에 연결된 화면 목록 + // 그룹에 직접 연결된 화면 목록 const groupScreens = useMemo(() => { if (!group.screens) return []; return group.screens @@ -142,6 +166,20 @@ function TreeNode({ .filter((s): s is ScreenDefinition => s !== undefined); }, [group.screens, screensMap]); + // 하위 그룹 포함 전체 화면 (복사용) + const allDescendantScreens = useMemo(() => { + const collected = new Map(); + const collectRecursive = (g: PopScreenGroup) => { + g.screens?.forEach((gs) => { + const screen = screensMap.get(gs.screen_id); + if (screen) collected.set(screen.screenId, screen); + }); + g.children?.forEach(collectRecursive); + }; + collectRecursive(group); + return Array.from(collected.values()); + }, [group, screensMap]); + // 루트 레벨(POP 화면)인지 확인 const isRootLevel = level === 0; @@ -193,8 +231,15 @@ function TreeNode({ )} - {/* 그룹명 - 루트는 볼드체 */} - {group.group_name} + {/* 그룹명 - 루트는 볼드체 + 회사코드 표시 */} + + {group.group_name} + {isRootLevel && group.company_code && ( + + {group.company_code === "*" ? "(전체)" : `(${group.company_code})`} + + )} + {/* 화면 수 배지 */} {group.screen_count && group.screen_count > 0 && ( @@ -224,6 +269,34 @@ function TreeNode({ 수정 + {allDescendantScreens.length > 0 && ( + { + const buildGroupInfo = (g: PopScreenGroup): GroupCopyInfo => { + const directScreenIds = (g.screens || []) + .map((gs) => gs.screen_id) + .filter((id) => screensMap.has(id)); + const children = (g.children || []).map((child) => ({ + sourceGroupId: child.id, + groupName: child.group_name, + groupCode: child.group_code, + screenIds: (child.screens || []) + .map((gs) => gs.screen_id) + .filter((id) => screensMap.has(id)), + })); + return { + sourceGroupId: g.id, + groupName: g.group_name, + groupCode: g.group_code, + screenIds: directScreenIds, + children, + }; + }; + onGroupCopy(allDescendantScreens, group.group_name, buildGroupInfo(group)); + }}> + + 카테고리 복사 ({allDescendantScreens.length}개 화면) + + )} onMoveGroupUp(group)} @@ -267,6 +340,8 @@ function TreeNode({ onGroupSelect={onGroupSelect} onScreenSelect={onScreenSelect} onScreenDesign={onScreenDesign} + onScreenSettings={onScreenSettings} + onScreenCopy={onScreenCopy} onEditGroup={onEditGroup} onDeleteGroup={onDeleteGroup} onAddSubGroup={onAddSubGroup} @@ -279,6 +354,7 @@ function TreeNode({ onMoveScreenUp={onMoveScreenUp} onMoveScreenDown={onMoveScreenDown} onDeleteScreen={onDeleteScreen} + onGroupCopy={onGroupCopy} /> ))} @@ -324,6 +400,14 @@ function TreeNode({ 설계 + onScreenSettings(screen)}> + + 설정 (이름 변경) + + onScreenCopy(screen)}> + + 복사 + onMoveScreenUp(screen, group.id)} @@ -378,6 +462,9 @@ export function PopCategoryTree({ selectedScreen, onScreenSelect, onScreenDesign, + onScreenSettings, + onScreenCopy, + onGroupCopy, onGroupSelect, searchTerm = "", }: PopCategoryTreeProps) { @@ -887,6 +974,8 @@ export function PopCategoryTree({ onGroupSelect={handleGroupSelect} onScreenSelect={onScreenSelect} onScreenDesign={onScreenDesign} + onScreenSettings={onScreenSettings || (() => {})} + onScreenCopy={onScreenCopy || (() => {})} onEditGroup={(g) => openGroupModal(undefined, g)} onDeleteGroup={(g) => { setDeletingGroup(g); @@ -902,66 +991,95 @@ export function PopCategoryTree({ onMoveScreenUp={handleMoveScreenUp} onMoveScreenDown={handleMoveScreenDown} onDeleteScreen={handleDeleteScreen} + onGroupCopy={onGroupCopy || (() => {})} /> ))} - {/* 미분류 화면 */} + {/* 미분류 화면 - 회사코드별 그룹핑 */} {ungroupedScreens.length > 0 && (
미분류 ({ungroupedScreens.length})
- {ungroupedScreens.map((screen) => ( -
onScreenSelect(screen)} - onDoubleClick={() => onScreenDesign(screen)} - > - - {screen.screenName} - #{screen.screenId} - - {/* 더보기 메뉴 */} - - - - - - onScreenDesign(screen)}> - - 설계 - - - openMoveModal(screen, null)}> - - 카테고리로 이동 - - - handleDeleteScreen(screen)} - > - - 화면 삭제 - - - -
- ))} + + {screen.screenName} + #{screen.screenId} + + + + + + + onScreenDesign(screen)}> + + 설계 + + onScreenSettings?.(screen)}> + + 설정 (이름 변경) + + onScreenCopy?.(screen)}> + + 복사 + + + openMoveModal(screen, null)}> + + 카테고리로 이동 + + + handleDeleteScreen(screen)} + > + + 화면 삭제 + + + +
+ ))} +
+ )); + })()}
)} diff --git a/frontend/components/pop/management/PopDeployModal.tsx b/frontend/components/pop/management/PopDeployModal.tsx new file mode 100644 index 00000000..46f8cc68 --- /dev/null +++ b/frontend/components/pop/management/PopDeployModal.tsx @@ -0,0 +1,560 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Loader2, Link2, Monitor, Folder, ChevronRight } from "lucide-react"; +import { screenApi } from "@/lib/api/screen"; +import { GroupCopyInfo } from "./PopCategoryTree"; +import { getCompanyList } from "@/lib/api/company"; +import { ScreenDefinition } from "@/types/screen"; +import { Company } from "@/types/company"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "sonner"; + +interface LinkedScreenInfo { + screenId: number; + screenName: string; + screenCode: string; + references: Array<{ + componentId: string; + referenceType: string; + }>; + deploy: boolean; + newScreenName: string; + newScreenCode: string; +} + +interface ScreenEntry { + screenId: number; + screenName: string; + newScreenName: string; + newScreenCode: string; + included: boolean; +} + +interface PopDeployModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + screen: ScreenDefinition | null; + groupScreens?: ScreenDefinition[]; + groupName?: string; + groupInfo?: GroupCopyInfo; + allScreens: ScreenDefinition[]; + onDeployed?: () => void; +} + +export function PopDeployModal({ + open, + onOpenChange, + screen, + groupScreens, + groupName, + groupInfo, + allScreens, + onDeployed, +}: PopDeployModalProps) { + const isGroupMode = !!(groupScreens && groupScreens.length > 0); + + const [companies, setCompanies] = useState([]); + const [targetCompanyCode, setTargetCompanyCode] = useState(""); + + // 단일 화면 모드 + const [screenName, setScreenName] = useState(""); + const [screenCode, setScreenCode] = useState(""); + const [linkedScreens, setLinkedScreens] = useState([]); + + // 그룹 모드 + const [groupEntries, setGroupEntries] = useState([]); + + const [analyzing, setAnalyzing] = useState(false); + const [deploying, setDeploying] = useState(false); + + // 회사 목록 로드 + useEffect(() => { + if (open) { + getCompanyList({ status: "active" }) + .then((list) => { + setCompanies(list.filter((c) => c.company_code !== "*")); + }) + .catch(console.error); + } + }, [open]); + + // 모달 열릴 때 초기화 + useEffect(() => { + if (!open) return; + + setTargetCompanyCode(""); + setLinkedScreens([]); + + if (isGroupMode && groupScreens) { + setGroupEntries( + groupScreens.map((s) => ({ + screenId: s.screenId, + screenName: s.screenName, + newScreenName: s.screenName, + newScreenCode: "", + included: true, + })), + ); + setScreenName(""); + setScreenCode(""); + } else if (screen) { + setScreenName(screen.screenName); + setScreenCode(""); + setGroupEntries([]); + analyzeLinks(screen.screenId); + } + }, [open, screen, groupScreens, isGroupMode]); + + // 회사 선택 시 화면 코드 자동 생성 + useEffect(() => { + if (!targetCompanyCode) return; + + if (isGroupMode) { + const count = groupEntries.filter((e) => e.included).length; + if (count > 0) { + screenApi + .generateMultipleScreenCodes(targetCompanyCode, count) + .then((codes) => { + let codeIdx = 0; + setGroupEntries((prev) => + prev.map((e) => + e.included + ? { ...e, newScreenCode: codes[codeIdx++] || "" } + : e, + ), + ); + }) + .catch(console.error); + } + } else { + const count = 1 + linkedScreens.filter((ls) => ls.deploy).length; + screenApi + .generateMultipleScreenCodes(targetCompanyCode, count) + .then((codes) => { + setScreenCode(codes[0] || ""); + setLinkedScreens((prev) => + prev.map((ls, idx) => ({ + ...ls, + newScreenCode: codes[idx + 1] || "", + })), + ); + }) + .catch(console.error); + } + }, [targetCompanyCode]); + + const analyzeLinks = async (screenId: number) => { + setAnalyzing(true); + try { + const result = await screenApi.analyzePopScreenLinks(screenId); + const linked: LinkedScreenInfo[] = result.linkedScreenIds.map( + (linkedId) => { + const linkedScreen = allScreens.find( + (s) => s.screenId === linkedId, + ); + const refs = result.references.filter( + (r) => r.targetScreenId === linkedId, + ); + return { + screenId: linkedId, + screenName: linkedScreen?.screenName || `화면 ${linkedId}`, + screenCode: linkedScreen?.screenCode || "", + references: refs.map((r) => ({ + componentId: r.componentId, + referenceType: r.referenceType, + })), + deploy: true, + newScreenName: linkedScreen?.screenName || `화면 ${linkedId}`, + newScreenCode: "", + }; + }, + ); + setLinkedScreens(linked); + } catch (error) { + console.error("연결 분석 실패:", error); + } finally { + setAnalyzing(false); + } + }; + + const handleDeploy = async () => { + if (!targetCompanyCode) return; + + setDeploying(true); + try { + let screensToSend: Array<{ + sourceScreenId: number; + screenName: string; + screenCode: string; + }>; + + if (isGroupMode) { + screensToSend = groupEntries + .filter((e) => e.included && e.newScreenCode) + .map((e) => ({ + sourceScreenId: e.screenId, + screenName: e.newScreenName, + screenCode: e.newScreenCode, + })); + } else { + if (!screen || !screenName || !screenCode) return; + screensToSend = [ + { + sourceScreenId: screen.screenId, + screenName, + screenCode, + }, + ...linkedScreens + .filter((ls) => ls.deploy) + .map((ls) => ({ + sourceScreenId: ls.screenId, + screenName: ls.newScreenName, + screenCode: ls.newScreenCode, + })), + ]; + } + + if (screensToSend.length === 0) { + toast.error("복사할 화면이 없습니다."); + return; + } + + const deployPayload: Parameters[0] = { + screens: screensToSend, + targetCompanyCode, + }; + + if (isGroupMode && groupInfo) { + deployPayload.groupStructure = groupInfo; + } + + const result = await screenApi.deployPopScreens(deployPayload); + + const groupMsg = result.createdGroups + ? ` (카테고리 ${result.createdGroups}개 생성)` + : ""; + toast.success( + `POP 화면 ${result.deployedScreens.length}개가 복사되었습니다.${groupMsg}`, + ); + onOpenChange(false); + onDeployed?.(); + } catch (error: any) { + toast.error(error?.response?.data?.message || "복사에 실패했습니다."); + } finally { + setDeploying(false); + } + }; + + const totalCount = isGroupMode + ? groupEntries.filter((e) => e.included).length + : 1 + linkedScreens.filter((ls) => ls.deploy).length; + + const canDeploy = isGroupMode + ? !deploying && targetCompanyCode && groupEntries.some((e) => e.included) + : !deploying && targetCompanyCode && screenName && screenCode; + + return ( + + + + + POP 화면 복사 + + + {isGroupMode + ? `"${groupName}" 카테고리의 화면 ${groupScreens!.length}개를 다른 회사로 복사합니다.` + : screen + ? `"${screen.screenName}" (ID: ${screen.screenId}) 화면을 다른 회사로 복사합니다.` + : "화면을 선택해주세요."} + + + +
+ {/* 대상 회사 선택 */} +
+ + +
+ + {/* ===== 그룹 모드: 카테고리 구조 + 화면 목록 ===== */} + {isGroupMode ? ( +
+ +
+ {groupInfo ? ( +
+ {/* 메인 카테고리 */} +
+ + {groupInfo.groupName} + + + 새 카테고리 생성 + +
+ {/* 메인 카테고리의 직접 화면 */} + {groupEntries + .filter((e) => groupInfo.screenIds.includes(e.screenId)) + .map((entry) => ( +
+ { + setGroupEntries((prev) => + prev.map((e) => + e.screenId === entry.screenId + ? { ...e, included: !!checked } + : e, + ), + ); + }} + /> + + + {entry.screenName} + + + #{entry.screenId} + +
+ ))} + {/* 하위 카테고리들 */} + {groupInfo.children?.map((child) => ( +
+
+ + {child.groupName} +
+ {groupEntries + .filter((e) => + child.screenIds.includes(e.screenId), + ) + .map((entry) => ( +
+ { + setGroupEntries((prev) => + prev.map((e) => + e.screenId === entry.screenId + ? { ...e, included: !!checked } + : e, + ), + ); + }} + /> + + + {entry.screenName} + + + #{entry.screenId} + +
+ ))} +
+ ))} +
+ ) : ( +
+ {groupEntries.map((entry) => ( +
+ { + setGroupEntries((prev) => + prev.map((e) => + e.screenId === entry.screenId + ? { ...e, included: !!checked } + : e, + ), + ); + }} + /> + + + {entry.screenName} + + + #{entry.screenId} + +
+ ))} +
+ )} +
+

+ 카테고리 구조와 화면 간 연결(cartScreenId 등)이 자동으로 + 복사됩니다. +

+
+ ) : ( + <> + {/* ===== 단일 모드: 화면명 + 코드 ===== */} +
+ + setScreenName(e.target.value)} + placeholder="화면 이름" + /> +
+ +
+ + +
+ + {/* 연결 화면 감지 */} + {analyzing ? ( +
+ + 연결된 화면을 분석 중입니다... +
+ ) : linkedScreens.length > 0 ? ( +
+
+ + 연결된 POP 화면 {linkedScreens.length}개 감지됨 +
+
+ {linkedScreens.map((ls) => ( +
+
+
{ls.screenName}
+
+ ID: {ls.screenId} |{" "} + {ls.references + .map((r) => r.referenceType) + .join(", ")} +
+
+
+ { + setLinkedScreens((prev) => + prev.map((item) => + item.screenId === ls.screenId + ? { ...item, deploy: !!checked } + : item, + ), + ); + }} + /> + 함께 복사 +
+
+ ))} +
+

+ 함께 복사하면 화면 간 연결(cartScreenId 등)이 새 ID로 자동 + 치환됩니다. +

+
+ ) : ( + !analyzing && ( +
+ 연결된 POP 화면이 없습니다. 이 화면만 복사됩니다. +
+ ) + )} + + )} +
+ + + + + +
+
+ ); +} diff --git a/frontend/components/pop/management/PopScreenSettingModal.tsx b/frontend/components/pop/management/PopScreenSettingModal.tsx index 7dd7a11e..3c260423 100644 --- a/frontend/components/pop/management/PopScreenSettingModal.tsx +++ b/frontend/components/pop/management/PopScreenSettingModal.tsx @@ -165,19 +165,26 @@ export function PopScreenSettingModal({ try { setSaving(true); - // 화면 기본 정보 업데이트 const screenUpdate: Partial = { screenName, description: screenDescription, }; + // screen_definitions 테이블에 화면명/설명 업데이트 + if (screenName !== screen.screenName || screenDescription !== (screen.description || "")) { + await screenApi.updateScreenInfo(screen.screenId, { + screenName, + description: screenDescription, + isActive: "Y", + }); + } + // 레이아웃에 하위 화면 정보 저장 const currentLayout = await screenApi.getLayoutPop(screen.screenId); const updatedLayout = { ...currentLayout, version: "pop-1.0", subScreens: subScreens, - // flow 배열 자동 생성 (메인 → 각 서브) flow: subScreens.map((sub) => ({ from: sub.triggerFrom || "main", to: sub.id, @@ -201,11 +208,11 @@ export function PopScreenSettingModal({ return ( - - + + POP 화면 설정 - {screen.screenName} ({screen.screenCode}) + {screen.screenName} [{screen.screenCode}] @@ -214,57 +221,57 @@ export function PopScreenSettingModal({ onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0" > - + - - 개요 + + 기본 정보 - + 하위 화면 {subScreens.length > 0 && ( - + {subScreens.length} )} - + 화면 흐름 - {/* 개요 탭 */} - + {/* 기본 정보 탭 */} + {loading ? ( -
+
) : ( -
-
+
+
setScreenName(e.target.value)} - placeholder="화면 이름" + placeholder="화면 이름을 입력하세요" className="h-8 text-xs sm:h-10 sm:text-sm" />
-
+
@@ -282,7 +289,7 @@ export function PopScreenSettingModal({
-
+
@@ -290,13 +297,13 @@ export function PopScreenSettingModal({ id="description" value={screenDescription} onChange={(e) => setScreenDescription(e.target.value)} - placeholder="화면에 대한 설명" + placeholder="화면에 대한 설명을 입력하세요" rows={3} className="text-xs sm:text-sm resize-none" />
-
+
@@ -307,7 +314,7 @@ export function PopScreenSettingModal({ placeholder="lucide 아이콘 이름 (예: Package)" className="h-8 text-xs sm:h-10 sm:text-sm" /> -

+

lucide-react 아이콘 이름을 입력하세요.

@@ -316,19 +323,19 @@ export function PopScreenSettingModal({ {/* 하위 화면 탭 */} - -
+ +
-

- 이 화면에서 열리는 모달, 드로어 등의 하위 화면을 관리합니다. +

+ 모달, 드로어 등 하위 화면을 관리합니다.

-
- + {subScreens.length === 0 ? (
@@ -339,12 +346,12 @@ export function PopScreenSettingModal({
) : (
- {subScreens.map((subScreen, index) => ( + {subScreens.map((subScreen) => (
- +
@@ -362,7 +369,7 @@ export function PopScreenSettingModal({ updateSubScreen(subScreen.id, "type", v) } > - + @@ -374,7 +381,7 @@ export function PopScreenSettingModal({
- + 트리거: