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:
parent
f6461ae563
commit
52b217c180
|
|
@ -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": "스캐너",
|
||||
|
|
|
|||
|
|
@ -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: "조건 입력 (텍스트/날짜/선택/모달)",
|
||||
},
|
||||
];
|
||||
|
||||
// 드래그 가능한 컴포넌트 아이템
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
|||
"pop-card-list": "카드 목록",
|
||||
"pop-button": "버튼",
|
||||
"pop-string-list": "리스트 목록",
|
||||
"pop-search": "검색",
|
||||
};
|
||||
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import "./pop-card-list";
|
|||
|
||||
import "./pop-button";
|
||||
import "./pop-string-list";
|
||||
import "./pop-search";
|
||||
|
||||
// 향후 추가될 컴포넌트들:
|
||||
// import "./pop-field";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
"직접" 선택 시 날짜 입력 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"],
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue