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-field": "필드",
|
||||||
"pop-button": "버튼",
|
"pop-button": "버튼",
|
||||||
"pop-string-list": "리스트 목록",
|
"pop-string-list": "리스트 목록",
|
||||||
|
"pop-search": "검색",
|
||||||
"pop-list": "리스트",
|
"pop-list": "리스트",
|
||||||
"pop-indicator": "인디케이터",
|
"pop-indicator": "인디케이터",
|
||||||
"pop-scanner": "스캐너",
|
"pop-scanner": "스캐너",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useDrag } from "react-dnd";
|
import { useDrag } from "react-dnd";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { PopComponentType } from "../types/pop-layout";
|
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";
|
import { DND_ITEM_TYPES } from "../constants";
|
||||||
|
|
||||||
// 컴포넌트 정의
|
// 컴포넌트 정의
|
||||||
|
|
@ -57,6 +57,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
||||||
icon: List,
|
icon: List,
|
||||||
description: "테이블 데이터를 리스트/카드로 표시",
|
description: "테이블 데이터를 리스트/카드로 표시",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "pop-search",
|
||||||
|
label: "검색",
|
||||||
|
icon: Search,
|
||||||
|
description: "조건 입력 (텍스트/날짜/선택/모달)",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 드래그 가능한 컴포넌트 아이템
|
// 드래그 가능한 컴포넌트 아이템
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
||||||
"pop-card-list": "카드 목록",
|
"pop-card-list": "카드 목록",
|
||||||
"pop-button": "버튼",
|
"pop-button": "버튼",
|
||||||
"pop-string-list": "리스트 목록",
|
"pop-string-list": "리스트 목록",
|
||||||
|
"pop-search": "검색",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
/**
|
/**
|
||||||
* POP 컴포넌트 타입
|
* 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-card-list": { colSpan: 4, rowSpan: 3 },
|
||||||
"pop-button": { colSpan: 2, rowSpan: 1 },
|
"pop-button": { colSpan: 2, rowSpan: 1 },
|
||||||
"pop-string-list": { colSpan: 4, rowSpan: 3 },
|
"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-button";
|
||||||
import "./pop-string-list";
|
import "./pop-string-list";
|
||||||
|
import "./pop-search";
|
||||||
|
|
||||||
// 향후 추가될 컴포넌트들:
|
// 향후 추가될 컴포넌트들:
|
||||||
// import "./pop-field";
|
// 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);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 필터 조건 구성
|
// 필터 조건 구성 (설정 패널 고정 필터 + 외부 검색 필터)
|
||||||
const filters: Record<string, unknown> = {};
|
const filters: Record<string, unknown> = {};
|
||||||
const parsedFilters = JSON.parse(dsFiltersKey) as Array<{ column?: string; value?: string }>;
|
const parsedFilters = JSON.parse(dsFiltersKey) as Array<{ column?: string; value?: string }>;
|
||||||
if (parsedFilters.length > 0) {
|
if (parsedFilters.length > 0) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue