feat(pop-search): 검색 컴포넌트 MVP 구현

- 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 <cursoragent@cursor.com>
This commit is contained in:
SeongHyun Kim 2026-02-23 17:16:38 +09:00
parent f6461ae563
commit 52b217c180
10 changed files with 999 additions and 3 deletions

View File

@ -60,6 +60,7 @@ const COMPONENT_TYPE_LABELS: Record<string, string> = {
"pop-field": "필드",
"pop-button": "버튼",
"pop-string-list": "리스트 목록",
"pop-search": "검색",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",

View File

@ -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: "조건 입력 (텍스트/날짜/선택/모달)",
},
];
// 드래그 가능한 컴포넌트 아이템

View File

@ -72,6 +72,7 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-card-list": "카드 목록",
"pop-button": "버튼",
"pop-string-list": "리스트 목록",
"pop-search": "검색",
};
// ========================================

View File

@ -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<PopComponentType, { colSpan: nu
"pop-card-list": { colSpan: 4, rowSpan: 3 },
"pop-button": { colSpan: 2, rowSpan: 1 },
"pop-string-list": { colSpan: 4, rowSpan: 3 },
"pop-search": { colSpan: 4, rowSpan: 2 },
};
/**

View File

@ -19,6 +19,7 @@ import "./pop-card-list";
import "./pop-button";
import "./pop-string-list";
import "./pop-search";
// 향후 추가될 컴포넌트들:
// import "./pop-field";

View File

@ -0,0 +1,328 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Search, ChevronRight } from "lucide-react";
import { usePopEvent } from "@/hooks/pop";
import type {
PopSearchConfig,
DatePresetOption,
} from "./types";
import { DATE_PRESET_LABELS, computeDateRange } from "./types";
// ========================================
// 메인 컴포넌트
// ========================================
interface PopSearchComponentProps {
config: PopSearchConfig;
label?: string;
screenId?: string;
}
const DEFAULT_CONFIG: PopSearchConfig = {
inputType: "text",
fieldName: "",
placeholder: "검색어 입력",
debounceMs: 500,
triggerOnEnter: true,
labelPosition: "top",
labelText: "",
labelVisible: true,
};
export function PopSearchComponent({
config: rawConfig,
label,
screenId,
}: PopSearchComponentProps) {
const config = { ...DEFAULT_CONFIG, ...(rawConfig || {}) };
const { publish, setSharedData } = usePopEvent(screenId || "");
const [value, setValue] = useState<unknown>(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 (
<div
className={cn(
"flex h-full w-full overflow-hidden",
showLabel && config.labelPosition === "left"
? "flex-row items-center gap-2 p-1.5"
: "flex-col justify-center gap-0.5 p-1.5"
)}
>
{showLabel && (
<span className="shrink-0 truncate text-[10px] font-medium text-muted-foreground">
{config.labelText}
</span>
)}
<div className="min-w-0">
<SearchInputRenderer
config={config}
value={value}
onChange={emitFilterChanged}
/>
</div>
</div>
);
}
// ========================================
// 서브타입 분기 렌더러
// ========================================
interface InputRendererProps {
config: PopSearchConfig;
value: unknown;
onChange: (v: unknown) => void;
}
function SearchInputRenderer({ config, value, onChange }: InputRendererProps) {
switch (config.inputType) {
case "text":
return (
<TextSearchInput
config={config}
value={String(value ?? "")}
onChange={onChange}
/>
);
case "select":
return (
<SelectSearchInput
config={config}
value={String(value ?? "")}
onChange={onChange}
/>
);
case "date-preset":
return <DatePresetSearchInput config={config} value={value} onChange={onChange} />;
case "toggle":
return (
<ToggleSearchInput value={Boolean(value)} onChange={onChange} />
);
case "modal-table":
case "modal-card":
case "modal-icon-grid":
return (
<ModalSearchInput
config={config}
value={String(value ?? "")}
/>
);
default:
return <PlaceholderInput inputType={config.inputType} />;
}
}
// ========================================
// 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<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
setInputValue(value);
}, [value]);
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="relative">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={inputValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={config.placeholder || "검색어 입력"}
className="h-8 pl-7 text-xs"
/>
</div>
);
}
// ========================================
// select 서브타입: 즉시 발행
// ========================================
interface SelectInputProps {
config: PopSearchConfig;
value: string;
onChange: (v: unknown) => void;
}
function SelectSearchInput({ config, value, onChange }: SelectInputProps) {
return (
<Select
value={value || undefined}
onValueChange={(v) => onChange(v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={config.placeholder || "선택"} />
</SelectTrigger>
<SelectContent>
{(config.options || []).map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
// ========================================
// 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<string, unknown>)
? (value as Record<string, unknown>).preset
: value;
const handleSelect = (preset: DatePresetOption) => {
if (preset === "custom") {
onChange({ preset: "custom", from: "", to: "" });
return;
}
const range = computeDateRange(preset);
if (range) onChange(range);
};
return (
<div className="flex flex-wrap gap-1">
{presets.map((preset) => (
<Button
key={preset}
variant={currentPreset === preset ? "default" : "outline"}
size="sm"
className="h-7 px-2 text-[10px]"
onClick={() => handleSelect(preset)}
>
{DATE_PRESET_LABELS[preset]}
</Button>
))}
</div>
);
}
// ========================================
// toggle 서브타입: Switch + 즉시 발행
// ========================================
interface ToggleInputProps {
value: boolean;
onChange: (v: unknown) => void;
}
function ToggleSearchInput({ value, onChange }: ToggleInputProps) {
return (
<div className="flex items-center gap-2">
<Switch
checked={value}
onCheckedChange={(checked) => onChange(checked)}
/>
<span className="text-xs text-muted-foreground">
{value ? "ON" : "OFF"}
</span>
</div>
);
}
// ========================================
// modal-* 서브타입: readonly 입력 + 아이콘 (MVP: UI만)
// ========================================
interface ModalInputProps {
config: PopSearchConfig;
value: string;
}
function ModalSearchInput({ config, value }: ModalInputProps) {
return (
<div
className="flex h-8 cursor-pointer items-center rounded-md border border-input bg-background px-3 transition-colors hover:bg-accent"
role="button"
tabIndex={0}
>
<span className="flex-1 truncate text-xs">
{value || config.placeholder || "선택..."}
</span>
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</div>
);
}
// ========================================
// 미구현 서브타입 플레이스홀더
// ========================================
function PlaceholderInput({ inputType }: { inputType: string }) {
return (
<div className="flex h-8 items-center rounded-md border border-dashed border-muted-foreground/30 px-3">
<span className="text-[10px] text-muted-foreground">
{inputType} ( )
</span>
</div>
);
}

View File

@ -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<PopSearchConfig>) => {
onUpdate({ ...cfg, ...partial });
};
const STEPS = ["기본 설정", "상세 설정"];
return (
<div className="space-y-3 overflow-y-auto pr-1 pb-32">
{/* Stepper 헤더 */}
<div className="flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-2">
{STEPS.map((s, i) => (
<button
key={s}
type="button"
onClick={() => setStep(i)}
className={cn(
"flex items-center gap-1 rounded px-2 py-1 text-[10px] font-medium transition-colors",
step === i
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-background text-[8px] font-bold text-foreground">
{i + 1}
</span>
{s}
</button>
))}
</div>
{/* STEP 1: 기본 설정 */}
{step === 0 && (
<StepBasicSettings cfg={cfg} update={update} />
)}
{/* STEP 2: 타입별 상세 설정 */}
{step === 1 && (
<StepDetailSettings cfg={cfg} update={update} />
)}
{/* 이전/다음 버튼 */}
<div className="flex justify-between pt-2">
<Button
variant="outline"
size="sm"
className="h-7 text-[10px]"
disabled={step === 0}
onClick={() => setStep(step - 1)}
>
<ChevronLeft className="mr-1 h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-[10px]"
disabled={step === STEPS.length - 1}
onClick={() => setStep(step + 1)}
>
<ChevronRight className="ml-1 h-3 w-3" />
</Button>
</div>
</div>
);
}
// ========================================
// STEP 1: 기본 설정
// ========================================
interface StepProps {
cfg: PopSearchConfig;
update: (partial: Partial<PopSearchConfig>) => void;
}
function StepBasicSettings({ cfg, update }: StepProps) {
return (
<div className="space-y-3">
{/* 입력 타입 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={cfg.inputType}
onValueChange={(v) => update({ inputType: v as SearchInputType })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SEARCH_INPUT_TYPE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 필드명 */}
<div className="space-y-1">
<Label className="text-[10px]">
<span className="text-destructive">*</span>
</Label>
<Input
value={cfg.fieldName}
onChange={(e) => update({ fieldName: e.target.value })}
placeholder="예: supplier_code"
className="h-8 text-xs"
/>
<p className="text-[9px] text-muted-foreground">
filter_changed
</p>
</div>
{/* 플레이스홀더 */}
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Input
value={cfg.placeholder || ""}
onChange={(e) => update({ placeholder: e.target.value })}
placeholder="입력 힌트 텍스트"
className="h-8 text-xs"
/>
</div>
{/* 라벨 표시 */}
<div className="flex items-center gap-2">
<Checkbox
id="labelVisible"
checked={cfg.labelVisible !== false}
onCheckedChange={(checked) =>
update({ labelVisible: Boolean(checked) })
}
/>
<Label htmlFor="labelVisible" className="text-[10px]">
</Label>
</div>
{/* 라벨 텍스트 + 위치 (라벨 표시 ON일 때만) */}
{cfg.labelVisible !== false && (
<>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={cfg.labelText || ""}
onChange={(e) => update({ labelText: e.target.value })}
placeholder="예: 거래처명"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={cfg.labelPosition || "top"}
onValueChange={(v) =>
update({ labelPosition: v as "top" | "left" })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="top" className="text-xs"> ()</SelectItem>
<SelectItem value="left" className="text-xs"></SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
</div>
);
}
// ========================================
// STEP 2: 타입별 상세 설정
// ========================================
function StepDetailSettings({ cfg, update }: StepProps) {
switch (cfg.inputType) {
case "text":
case "number":
return <TextDetailSettings cfg={cfg} update={update} />;
case "select":
return <SelectDetailSettings cfg={cfg} update={update} />;
case "date-preset":
return <DatePresetDetailSettings cfg={cfg} update={update} />;
case "modal-table":
case "modal-card":
case "modal-icon-grid":
return <ModalDetailSettings cfg={cfg} update={update} />;
case "toggle":
return (
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-[10px] text-muted-foreground">
. ON/OFF .
</p>
</div>
);
default:
return (
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-[10px] text-muted-foreground">
{cfg.inputType} .
</p>
</div>
);
}
}
// ========================================
// text/number 상세 설정
// ========================================
function TextDetailSettings({ cfg, update }: StepProps) {
return (
<div className="space-y-3">
{/* 디바운스 */}
<div className="space-y-1">
<Label className="text-[10px]"> (ms)</Label>
<Input
type="number"
value={cfg.debounceMs ?? 500}
onChange={(e) =>
update({ debounceMs: Math.max(0, Number(e.target.value)) })
}
min={0}
max={5000}
step={100}
className="h-8 text-xs"
/>
<p className="text-[9px] text-muted-foreground">
. 0 (권장: 300~500)
</p>
</div>
{/* Enter 발행 */}
<div className="flex items-center gap-2">
<Checkbox
id="triggerOnEnter"
checked={cfg.triggerOnEnter !== false}
onCheckedChange={(checked) =>
update({ triggerOnEnter: Boolean(checked) })
}
/>
<Label htmlFor="triggerOnEnter" className="text-[10px]">
Enter
</Label>
</div>
</div>
);
}
// ========================================
// 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 (
<div className="space-y-3">
<Label className="text-[10px]"> </Label>
{options.length === 0 && (
<p className="text-[9px] text-muted-foreground">
. .
</p>
)}
{options.map((opt, i) => (
<div key={i} className="flex items-center gap-1">
<Input
value={opt.value}
onChange={(e) => updateOption(i, "value", e.target.value)}
placeholder="값"
className="h-7 flex-1 text-[10px]"
/>
<Input
value={opt.label}
onChange={(e) => updateOption(i, "label", e.target.value)}
placeholder="라벨"
className="h-7 flex-1 text-[10px]"
/>
<button
type="button"
onClick={() => removeOption(i)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
<Button
variant="outline"
size="sm"
className="h-7 w-full text-[10px]"
onClick={addOption}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
);
}
// ========================================
// 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 (
<div className="space-y-3">
<Label className="text-[10px]"> </Label>
{ALL_PRESETS.map((preset) => (
<div key={preset} className="flex items-center gap-2">
<Checkbox
id={`preset_${preset}`}
checked={activePresets.includes(preset)}
onCheckedChange={() => togglePreset(preset)}
/>
<Label htmlFor={`preset_${preset}`} className="text-[10px]">
{DATE_PRESET_LABELS[preset]}
</Label>
</div>
))}
{activePresets.includes("custom") && (
<p className="text-[9px] text-muted-foreground">
&quot;&quot; UI가 ( )
</p>
)}
</div>
);
}
// ========================================
// modal-* 상세 설정
// ========================================
function ModalDetailSettings({ cfg, update }: StepProps) {
const mc = cfg.modalConfig || { modalCanvasId: "", displayField: "", valueField: "" };
const updateModal = (partial: Partial<typeof mc>) => {
update({ modalConfig: { ...mc, ...partial } });
};
return (
<div className="space-y-3">
<div className="rounded-lg border border-dashed border-muted-foreground/30 p-3">
<p className="text-[10px] text-muted-foreground">
.
.
</p>
</div>
{/* 모달 캔버스 ID */}
<div className="space-y-1">
<Label className="text-[10px]"> ID</Label>
<Input
value={mc.modalCanvasId}
onChange={(e) => updateModal({ modalCanvasId: e.target.value })}
placeholder="예: modal-supplier"
className="h-8 text-xs"
/>
</div>
{/* 표시 필드 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={mc.displayField}
onChange={(e) => updateModal({ displayField: e.target.value })}
placeholder="예: supplier_name"
className="h-8 text-xs"
/>
<p className="text-[9px] text-muted-foreground">
</p>
</div>
{/* 값 필드 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={mc.valueField}
onChange={(e) => updateModal({ valueField: e.target.value })}
placeholder="예: supplier_code"
className="h-8 text-xs"
/>
<p className="text-[9px] text-muted-foreground">
</p>
</div>
</div>
);
}

View File

@ -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 (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
<span className="text-[10px] font-medium text-muted-foreground">
{displayLabel}
</span>
<div className="flex h-6 w-full items-center rounded border border-dashed border-muted-foreground/30 px-2">
<span className="text-[9px] text-muted-foreground">
{cfg.placeholder || cfg.inputType}
</span>
</div>
</div>
);
}
PopComponentRegistry.registerComponent({
id: "pop-search",
name: "검색",
description: "조건 입력 (텍스트/날짜/선택/모달)",
category: "input",
icon: "Search",
component: PopSearchComponent,
configPanel: PopSearchConfigPanel,
preview: PopSearchPreviewComponent,
defaultProps: defaultConfig,
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});

View File

@ -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<DatePresetOption, string> = {
today: "오늘",
"this-week": "이번주",
"this-month": "이번달",
custom: "직접",
};
/** 입력 타입 라벨 매핑 (설정 패널용) */
export const SEARCH_INPUT_TYPE_LABELS: Record<SearchInputType, string> = {
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;
}
}

View File

@ -188,7 +188,7 @@ export function PopStringListComponent({
setError(null);
try {
// 필터 조건 구성
// 필터 조건 구성 (설정 패널 고정 필터 + 외부 검색 필터)
const filters: Record<string, unknown> = {};
const parsedFilters = JSON.parse(dsFiltersKey) as Array<{ column?: string; value?: string }>;
if (parsedFilters.length > 0) {