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

329 lines
8.7 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 } 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>
);
}