ERP-node/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx

354 lines
9.6 KiB
TypeScript

"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, DEFAULT_SEARCH_CONFIG } from "./types";
// ========================================
// 메인 컴포넌트
// ========================================
interface PopSearchComponentProps {
config: PopSearchConfig;
label?: string;
screenId?: string;
componentId?: string;
}
const DEFAULT_CONFIG = DEFAULT_SEARCH_CONFIG;
export function PopSearchComponent({
config: rawConfig,
label,
screenId,
componentId,
}: PopSearchComponentProps) {
const config = { ...DEFAULT_CONFIG, ...(rawConfig || {}) };
const { publish, subscribe, setSharedData } = usePopEvent(screenId || "");
const [value, setValue] = useState<unknown>(config.defaultValue ?? "");
const fieldKey = config.fieldName || componentId || "search";
const emitFilterChanged = useCallback(
(newValue: unknown) => {
setValue(newValue);
setSharedData(`search_${fieldKey}`, newValue);
// 표준 출력 이벤트 (연결 시스템용)
if (componentId) {
publish(`__comp_output__${componentId}__filter_value`, {
fieldName: fieldKey,
value: newValue,
});
}
// 레거시 호환
publish("filter_changed", { [fieldKey]: newValue });
},
[fieldKey, publish, setSharedData, componentId]
);
// 외부 값 수신 (스캔 결과, 모달 선택 등)
useEffect(() => {
if (!componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__set_value`,
(payload: unknown) => {
const data = payload as { value?: unknown } | unknown;
const incoming = typeof data === "object" && data && "value" in data
? (data as { value: unknown }).value
: data;
emitFilterChanged(incoming);
}
);
return unsub;
}, [componentId, subscribe, emitFilterChanged]);
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":
case "number":
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);
}
};
const isNumber = config.inputType === "number";
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
type={isNumber ? "number" : "text"}
inputMode={isNumber ? "numeric" : undefined}
value={inputValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={config.placeholder || (isNumber ? "숫자 입력" : "검색어 입력")}
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>
);
}