From 52b217c1804c342744a70ee88232c8ac3f6069f9 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 23 Feb 2026 17:16:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop-search):=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20MVP=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pop-search 컴포넌트 신규 추가 (Component, Config, types, index) - 입력 타입: text, number, date, date-preset, select, multi-select, combo, modal-table, modal-card, modal-icon-grid, toggle - 디자이너 팔레트, 레지스트리, 타입, 렌더러 라벨 등록 - 기본 그리드 크기 4x2, labelText/labelVisible 설정 지원 - filter_changed 이벤트 발행 (연결 시스템 미적용, 추후 dataFlow 기반으로 전환 예정) Co-authored-by: Cursor --- .../designer/panels/ComponentEditorPanel.tsx | 1 + .../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 | 1 + .../pop-search/PopSearchComponent.tsx | 328 ++++++++++++ .../pop-search/PopSearchConfig.tsx | 487 ++++++++++++++++++ .../pop-components/pop-search/index.tsx | 49 ++ .../pop-components/pop-search/types.ts | 122 +++++ .../PopStringListComponent.tsx | 2 +- 10 files changed, 999 insertions(+), 3 deletions(-) create mode 100644 frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx create mode 100644 frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx create mode 100644 frontend/lib/registry/pop-components/pop-search/index.tsx create mode 100644 frontend/lib/registry/pop-components/pop-search/types.ts diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index 1b339825..a7cde997 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -60,6 +60,7 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-field": "필드", "pop-button": "버튼", "pop-string-list": "리스트 목록", + "pop-search": "검색", "pop-list": "리스트", "pop-indicator": "인디케이터", "pop-scanner": "스캐너", diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx index 1be9bcba..42b1ee06 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 } from "lucide-react"; +import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search } from "lucide-react"; import { DND_ITEM_TYPES } from "../constants"; // 컴포넌트 정의 @@ -57,6 +57,12 @@ const PALETTE_ITEMS: PaletteItem[] = [ icon: List, description: "테이블 데이터를 리스트/카드로 표시", }, + { + type: "pop-search", + label: "검색", + icon: Search, + description: "조건 입력 (텍스트/날짜/선택/모달)", + }, ]; // 드래그 가능한 컴포넌트 아이템 diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index b8ec7db5..968d2534 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -72,6 +72,7 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-card-list": "카드 목록", "pop-button": "버튼", "pop-string-list": "리스트 목록", + "pop-search": "검색", }; // ======================================== diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index 9cea1b8a..c17cecd6 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"; +export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search"; /** * 데이터 흐름 정의 @@ -350,6 +350,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record(config.defaultValue ?? ""); + + const emitFilterChanged = useCallback( + (newValue: unknown) => { + if (!config.fieldName) return; + setValue(newValue); + setSharedData(`search_${config.fieldName}`, newValue); + publish("filter_changed", { [config.fieldName]: newValue }); + }, + [config.fieldName, publish, setSharedData] + ); + + const showLabel = config.labelVisible !== false && !!config.labelText; + + return ( +
+ {showLabel && ( + + {config.labelText} + + )} +
+ +
+
+ ); +} + +// ======================================== +// 서브타입 분기 렌더러 +// ======================================== + +interface InputRendererProps { + config: PopSearchConfig; + value: unknown; + onChange: (v: unknown) => void; +} + +function SearchInputRenderer({ config, value, onChange }: InputRendererProps) { + switch (config.inputType) { + case "text": + return ( + + ); + case "select": + return ( + + ); + case "date-preset": + return ; + case "toggle": + return ( + + ); + case "modal-table": + case "modal-card": + case "modal-icon-grid": + return ( + + ); + default: + return ; + } +} + +// ======================================== +// text 서브타입: 디바운스 + Enter +// ======================================== + +interface TextInputProps { + config: PopSearchConfig; + value: string; + onChange: (v: unknown) => void; +} + +function TextSearchInput({ config, value, onChange }: TextInputProps) { + const [inputValue, setInputValue] = useState(value); + const debounceRef = useRef | null>(null); + + useEffect(() => { + setInputValue(value); + }, [value]); + + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, []); + + const handleChange = (e: React.ChangeEvent) => { + const v = e.target.value; + setInputValue(v); + + if (debounceRef.current) clearTimeout(debounceRef.current); + const ms = config.debounceMs ?? 500; + if (ms > 0) { + debounceRef.current = setTimeout(() => onChange(v), ms); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && config.triggerOnEnter !== false) { + if (debounceRef.current) clearTimeout(debounceRef.current); + onChange(inputValue); + } + }; + + return ( +
+ + +
+ ); +} + +// ======================================== +// select 서브타입: 즉시 발행 +// ======================================== + +interface SelectInputProps { + config: PopSearchConfig; + value: string; + onChange: (v: unknown) => void; +} + +function SelectSearchInput({ config, value, onChange }: SelectInputProps) { + return ( + + ); +} + +// ======================================== +// date-preset 서브타입: 탭 버튼 + 즉시 발행 +// ======================================== + +interface DatePresetInputProps { + config: PopSearchConfig; + value: unknown; + onChange: (v: unknown) => void; +} + +function DatePresetSearchInput({ config, value, onChange }: DatePresetInputProps) { + const presets: DatePresetOption[] = + config.datePresets || ["today", "this-week", "this-month"]; + + const currentPreset = + value && typeof value === "object" && "preset" in (value as Record) + ? (value as Record).preset + : value; + + const handleSelect = (preset: DatePresetOption) => { + if (preset === "custom") { + onChange({ preset: "custom", from: "", to: "" }); + return; + } + const range = computeDateRange(preset); + if (range) onChange(range); + }; + + return ( +
+ {presets.map((preset) => ( + + ))} +
+ ); +} + +// ======================================== +// toggle 서브타입: Switch + 즉시 발행 +// ======================================== + +interface ToggleInputProps { + value: boolean; + onChange: (v: unknown) => void; +} + +function ToggleSearchInput({ value, onChange }: ToggleInputProps) { + return ( +
+ onChange(checked)} + /> + + {value ? "ON" : "OFF"} + +
+ ); +} + +// ======================================== +// modal-* 서브타입: readonly 입력 + 아이콘 (MVP: UI만) +// ======================================== + +interface ModalInputProps { + config: PopSearchConfig; + value: string; +} + +function ModalSearchInput({ config, value }: ModalInputProps) { + return ( +
+ + {value || config.placeholder || "선택..."} + + +
+ ); +} + +// ======================================== +// 미구현 서브타입 플레이스홀더 +// ======================================== + +function PlaceholderInput({ inputType }: { inputType: string }) { + return ( +
+ + {inputType} (후속 구현 예정) + +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx new file mode 100644 index 00000000..5b66ef57 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx @@ -0,0 +1,487 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ChevronLeft, ChevronRight, Plus, Trash2 } from "lucide-react"; +import type { + PopSearchConfig, + SearchInputType, + DatePresetOption, +} from "./types"; +import { SEARCH_INPUT_TYPE_LABELS, DATE_PRESET_LABELS } from "./types"; + +// ======================================== +// 기본값 +// ======================================== + +const DEFAULT_CONFIG: PopSearchConfig = { + inputType: "text", + fieldName: "", + placeholder: "검색어 입력", + debounceMs: 500, + triggerOnEnter: true, + labelPosition: "top", + labelText: "", + labelVisible: true, +}; + +// ======================================== +// 설정 패널 메인 +// ======================================== + +interface ConfigPanelProps { + config: PopSearchConfig | undefined; + onUpdate: (config: PopSearchConfig) => void; +} + +export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) { + const [step, setStep] = useState(0); + const cfg = { ...DEFAULT_CONFIG, ...(config || {}) }; + + const update = (partial: Partial) => { + onUpdate({ ...cfg, ...partial }); + }; + + const STEPS = ["기본 설정", "상세 설정"]; + + return ( +
+ {/* Stepper 헤더 */} +
+ {STEPS.map((s, i) => ( + + ))} +
+ + {/* STEP 1: 기본 설정 */} + {step === 0 && ( + + )} + + {/* STEP 2: 타입별 상세 설정 */} + {step === 1 && ( + + )} + + {/* 이전/다음 버튼 */} +
+ + +
+
+ ); +} + +// ======================================== +// STEP 1: 기본 설정 +// ======================================== + +interface StepProps { + cfg: PopSearchConfig; + update: (partial: Partial) => void; +} + +function StepBasicSettings({ cfg, update }: StepProps) { + return ( +
+ {/* 입력 타입 */} +
+ + +
+ + {/* 필드명 */} +
+ + update({ fieldName: e.target.value })} + placeholder="예: supplier_code" + className="h-8 text-xs" + /> +

+ filter_changed 이벤트에서 이 이름으로 값이 전달됩니다 +

+
+ + {/* 플레이스홀더 */} +
+ + update({ placeholder: e.target.value })} + placeholder="입력 힌트 텍스트" + className="h-8 text-xs" + /> +
+ + {/* 라벨 표시 */} +
+ + update({ labelVisible: Boolean(checked) }) + } + /> + +
+ + {/* 라벨 텍스트 + 위치 (라벨 표시 ON일 때만) */} + {cfg.labelVisible !== false && ( + <> +
+ + update({ labelText: e.target.value })} + placeholder="예: 거래처명" + className="h-8 text-xs" + /> +
+ +
+ + +
+ + )} +
+ ); +} + +// ======================================== +// STEP 2: 타입별 상세 설정 +// ======================================== + +function StepDetailSettings({ cfg, update }: StepProps) { + switch (cfg.inputType) { + case "text": + case "number": + return ; + case "select": + return ; + case "date-preset": + return ; + case "modal-table": + case "modal-card": + case "modal-icon-grid": + return ; + case "toggle": + return ( +
+

+ 토글은 추가 설정이 없습니다. ON/OFF 값이 바로 전달됩니다. +

+
+ ); + default: + return ( +
+

+ {cfg.inputType} 타입의 상세 설정은 후속 구현 예정입니다. +

+
+ ); + } +} + +// ======================================== +// text/number 상세 설정 +// ======================================== + +function TextDetailSettings({ cfg, update }: StepProps) { + return ( +
+ {/* 디바운스 */} +
+ + + update({ debounceMs: Math.max(0, Number(e.target.value)) }) + } + min={0} + max={5000} + step={100} + className="h-8 text-xs" + /> +

+ 입력 후 대기 시간. 0이면 즉시 발행 (권장: 300~500) +

+
+ + {/* Enter 발행 */} +
+ + update({ triggerOnEnter: Boolean(checked) }) + } + /> + +
+
+ ); +} + +// ======================================== +// select 상세 설정: 정적 옵션 편집 +// ======================================== + +function SelectDetailSettings({ cfg, update }: StepProps) { + const options = cfg.options || []; + + const addOption = () => { + update({ + options: [ + ...options, + { value: `opt_${options.length + 1}`, label: `옵션 ${options.length + 1}` }, + ], + }); + }; + + const removeOption = (index: number) => { + update({ options: options.filter((_, i) => i !== index) }); + }; + + const updateOption = (index: number, field: "value" | "label", val: string) => { + const next = options.map((opt, i) => + i === index ? { ...opt, [field]: val } : opt + ); + update({ options: next }); + }; + + return ( +
+ + + {options.length === 0 && ( +

+ 옵션이 없습니다. 아래 버튼으로 추가하세요. +

+ )} + + {options.map((opt, i) => ( +
+ updateOption(i, "value", e.target.value)} + placeholder="값" + className="h-7 flex-1 text-[10px]" + /> + updateOption(i, "label", e.target.value)} + placeholder="라벨" + className="h-7 flex-1 text-[10px]" + /> + +
+ ))} + + +
+ ); +} + +// ======================================== +// date-preset 상세 설정: 프리셋 선택 +// ======================================== + +function DatePresetDetailSettings({ cfg, update }: StepProps) { + const ALL_PRESETS: DatePresetOption[] = [ + "today", + "this-week", + "this-month", + "custom", + ]; + const activePresets = cfg.datePresets || ["today", "this-week", "this-month"]; + + const togglePreset = (preset: DatePresetOption) => { + const next = activePresets.includes(preset) + ? activePresets.filter((p) => p !== preset) + : [...activePresets, preset]; + update({ datePresets: next.length > 0 ? next : ["today"] }); + }; + + return ( +
+ + + {ALL_PRESETS.map((preset) => ( +
+ togglePreset(preset)} + /> + +
+ ))} + + {activePresets.includes("custom") && ( +

+ "직접" 선택 시 날짜 입력 UI가 표시됩니다 (후속 구현) +

+ )} +
+ ); +} + +// ======================================== +// modal-* 상세 설정 +// ======================================== + +function ModalDetailSettings({ cfg, update }: StepProps) { + const mc = cfg.modalConfig || { modalCanvasId: "", displayField: "", valueField: "" }; + + const updateModal = (partial: Partial) => { + update({ modalConfig: { ...mc, ...partial } }); + }; + + return ( +
+
+

+ 모달 캔버스 연동은 모달 시스템 구현 후 활성화됩니다. + 현재는 설정값만 저장됩니다. +

+
+ + {/* 모달 캔버스 ID */} +
+ + updateModal({ modalCanvasId: e.target.value })} + placeholder="예: modal-supplier" + className="h-8 text-xs" + /> +
+ + {/* 표시 필드 */} +
+ + updateModal({ displayField: e.target.value })} + placeholder="예: supplier_name" + className="h-8 text-xs" + /> +

+ 선택 후 입력란에 표시할 필드명 +

+
+ + {/* 값 필드 */} +
+ + updateModal({ valueField: e.target.value })} + placeholder="예: supplier_code" + className="h-8 text-xs" + /> +

+ 필터 값으로 사용할 필드명 +

+
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-search/index.tsx b/frontend/lib/registry/pop-components/pop-search/index.tsx new file mode 100644 index 00000000..3d5bbc56 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-search/index.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { PopComponentRegistry } from "../../PopComponentRegistry"; +import { PopSearchComponent } from "./PopSearchComponent"; +import { PopSearchConfigPanel } from "./PopSearchConfig"; +import type { PopSearchConfig } from "./types"; + +const defaultConfig: PopSearchConfig = { + inputType: "text", + fieldName: "", + placeholder: "검색어 입력", + debounceMs: 500, + triggerOnEnter: true, + labelPosition: "top", + labelText: "", + labelVisible: true, +}; + +function PopSearchPreviewComponent({ config, label }: { config?: PopSearchConfig; label?: string }) { + const cfg = config || defaultConfig; + const displayLabel = label || cfg.fieldName || "검색"; + + return ( +
+ + {displayLabel} + +
+ + {cfg.placeholder || cfg.inputType} + +
+
+ ); +} + +PopComponentRegistry.registerComponent({ + id: "pop-search", + name: "검색", + description: "조건 입력 (텍스트/날짜/선택/모달)", + category: "input", + icon: "Search", + component: PopSearchComponent, + configPanel: PopSearchConfigPanel, + preview: PopSearchPreviewComponent, + defaultProps: defaultConfig, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-search/types.ts b/frontend/lib/registry/pop-components/pop-search/types.ts new file mode 100644 index 00000000..7eaf06ec --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-search/types.ts @@ -0,0 +1,122 @@ +// ===== pop-search 전용 타입 ===== +// 단일 필드 검색 컴포넌트. 그리드 한 칸 = 검색 필드 하나. + +/** 검색 필드 입력 타입 (11종) */ +export type SearchInputType = + | "text" + | "number" + | "date" + | "date-preset" + | "select" + | "multi-select" + | "combo" + | "modal-table" + | "modal-card" + | "modal-icon-grid" + | "toggle"; + +/** 날짜 프리셋 옵션 */ +export type DatePresetOption = "today" | "this-week" | "this-month" | "custom"; + +/** 셀렉트 옵션 (정적 목록) */ +export interface SelectOption { + value: string; + label: string; +} + +/** 셀렉트 옵션 데이터 소스 (DB에서 동적 로딩) */ +export interface SelectDataSource { + tableName: string; + valueColumn: string; + labelColumn: string; + sortColumn?: string; + sortDirection?: "asc" | "desc"; +} + +/** 모달 선택 설정 (modal-table / modal-card / modal-icon-grid 서브타입 전용) */ +export interface ModalSelectConfig { + modalCanvasId: string; + displayField: string; + valueField: string; + returnEvent?: string; +} + +/** pop-search 전체 설정 */ +export interface PopSearchConfig { + inputType: SearchInputType; + fieldName: string; + placeholder?: string; + defaultValue?: unknown; + + // text/number 전용 + debounceMs?: number; + triggerOnEnter?: boolean; + + // select/multi-select 전용 + options?: SelectOption[]; + optionsDataSource?: SelectDataSource; + + // date-preset 전용 + datePresets?: DatePresetOption[]; + + // modal-* 전용 + modalConfig?: ModalSelectConfig; + + // 라벨 + labelText?: string; + labelVisible?: boolean; + + // 스타일 + labelPosition?: "top" | "left"; +} + +/** 날짜 프리셋 라벨 매핑 */ +export const DATE_PRESET_LABELS: Record = { + today: "오늘", + "this-week": "이번주", + "this-month": "이번달", + custom: "직접", +}; + +/** 입력 타입 라벨 매핑 (설정 패널용) */ +export const SEARCH_INPUT_TYPE_LABELS: Record = { + text: "텍스트", + number: "숫자", + date: "날짜", + "date-preset": "날짜 프리셋", + select: "단일 선택", + "multi-select": "다중 선택", + combo: "자동완성", + "modal-table": "모달 (테이블)", + "modal-card": "모달 (카드)", + "modal-icon-grid": "모달 (아이콘)", + toggle: "토글", +}; + +/** 날짜 범위 계산 (date-preset -> 실제 날짜) */ +export function computeDateRange( + preset: DatePresetOption +): { preset: DatePresetOption; from: string; to: string } | null { + const now = new Date(); + const fmt = (d: Date) => d.toISOString().split("T")[0]; + + switch (preset) { + case "today": + return { preset, from: fmt(now), to: fmt(now) }; + case "this-week": { + const day = now.getDay(); + const mon = new Date(now); + mon.setDate(now.getDate() - ((day + 6) % 7)); + const sun = new Date(mon); + sun.setDate(mon.getDate() + 6); + return { preset, from: fmt(mon), to: fmt(sun) }; + } + case "this-month": { + const first = new Date(now.getFullYear(), now.getMonth(), 1); + const last = new Date(now.getFullYear(), now.getMonth() + 1, 0); + return { preset, from: fmt(first), to: fmt(last) }; + } + case "custom": + return null; + } +} diff --git a/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx index 08d4fadf..2270e12f 100644 --- a/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx @@ -188,7 +188,7 @@ export function PopStringListComponent({ setError(null); try { - // 필터 조건 구성 + // 필터 조건 구성 (설정 패널 고정 필터 + 외부 검색 필터) const filters: Record = {}; const parsedFilters = JSON.parse(dsFiltersKey) as Array<{ column?: string; value?: string }>; if (parsedFilters.length > 0) {