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

690 lines
22 KiB
TypeScript

"use client";
import { useState, useCallback, useEffect, useRef, useMemo } 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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { Search, ChevronRight, Loader2, X } from "lucide-react";
import { usePopEvent } from "@/hooks/pop";
import { dataApi } from "@/lib/api/data";
import type {
PopSearchConfig,
DatePresetOption,
ModalSelectConfig,
ModalSearchMode,
ModalFilterTab,
} from "./types";
import {
DATE_PRESET_LABELS,
computeDateRange,
DEFAULT_SEARCH_CONFIG,
normalizeInputType,
MODAL_FILTER_TAB_LABELS,
getGroupKey,
} 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 [modalDisplayText, setModalDisplayText] = useState("");
const [simpleModalOpen, setSimpleModalOpen] = useState(false);
const fieldKey = config.fieldName || componentId || "search";
const normalizedType = normalizeInputType(config.inputType as string);
const isModalType = normalizedType === "modal";
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 handleModalOpen = useCallback(() => {
if (!config.modalConfig) return;
setSimpleModalOpen(true);
}, [config.modalConfig]);
const handleSimpleModalSelect = useCallback(
(row: Record<string, unknown>) => {
const mc = config.modalConfig;
const display = mc?.displayField ? String(row[mc.displayField] ?? "") : "";
const filterVal = mc?.valueField ? String(row[mc.valueField] ?? "") : "";
setModalDisplayText(display);
emitFilterChanged(filterVal);
setSimpleModalOpen(false);
},
[config.modalConfig, 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}
modalDisplayText={modalDisplayText}
onModalOpen={handleModalOpen}
/>
</div>
{isModalType && config.modalConfig && (
<ModalDialog
open={simpleModalOpen}
onOpenChange={setSimpleModalOpen}
modalConfig={config.modalConfig}
title={config.labelText || "선택"}
onSelect={handleSimpleModalSelect}
/>
)}
</div>
);
}
// ========================================
// 서브타입 분기 렌더러
// ========================================
interface InputRendererProps {
config: PopSearchConfig;
value: unknown;
onChange: (v: unknown) => void;
modalDisplayText?: string;
onModalOpen?: () => void;
}
function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen }: InputRendererProps) {
const normalized = normalizeInputType(config.inputType as string);
switch (normalized) {
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":
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} />;
default:
return <PlaceholderInput inputType={config.inputType} />;
}
}
// ========================================
// text 서브타입
// ========================================
function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) {
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 서브타입
// ========================================
function SelectSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) {
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 서브타입
// ========================================
function DatePresetSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: unknown; onChange: (v: unknown) => void }) {
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 서브타입
// ========================================
function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v: unknown) => void }) {
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 서브타입: 읽기 전용 표시 + 클릭으로 모달 열기
// ========================================
function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchConfig; displayText: string; onClick?: () => void }) {
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}
onClick={onClick}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onClick?.(); }}
>
<span className="flex-1 truncate text-xs">{displayText || 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>
);
}
// ========================================
// 검색 방식별 문자열 매칭
// ========================================
function matchSearchMode(cellValue: string, term: string, mode: ModalSearchMode): boolean {
const lower = cellValue.toLowerCase();
const tLower = term.toLowerCase();
switch (mode) {
case "starts-with": return lower.startsWith(tLower);
case "equals": return lower === tLower;
case "contains":
default: return lower.includes(tLower);
}
}
// ========================================
// 아이콘 색상 생성 (이름 기반 결정적 색상)
// ========================================
const ICON_COLORS = [
"bg-red-500", "bg-orange-500", "bg-amber-500", "bg-yellow-500",
"bg-lime-500", "bg-green-500", "bg-emerald-500", "bg-teal-500",
"bg-cyan-500", "bg-sky-500", "bg-blue-500", "bg-indigo-500",
"bg-violet-500", "bg-purple-500", "bg-fuchsia-500", "bg-pink-500",
];
function getIconColor(text: string): string {
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = text.charCodeAt(i) + ((hash << 5) - hash);
}
return ICON_COLORS[Math.abs(hash) % ICON_COLORS.length];
}
// ========================================
// 모달 Dialog: 테이블 / 아이콘 뷰 + 필터 탭
// ========================================
interface ModalDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
modalConfig: ModalSelectConfig;
title: string;
onSelect: (row: Record<string, unknown>) => void;
}
function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: ModalDialogProps) {
const [searchText, setSearchText] = useState("");
const [allRows, setAllRows] = useState<Record<string, unknown>[]>([]);
const [loading, setLoading] = useState(false);
const [activeFilterTab, setActiveFilterTab] = useState<ModalFilterTab | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const {
tableName,
displayColumns,
searchColumns,
searchMode = "contains",
filterTabs,
columnLabels,
displayStyle = "table",
displayField,
} = modalConfig;
const colsToShow = displayColumns && displayColumns.length > 0 ? displayColumns : [];
const hasFilterTabs = filterTabs && filterTabs.length > 0;
// 데이터 로드
const fetchData = useCallback(async () => {
if (!tableName) return;
setLoading(true);
try {
const result = await dataApi.getTableData(tableName, { page: 1, size: 200 });
setAllRows(result.data || []);
} catch {
setAllRows([]);
} finally {
setLoading(false);
}
}, [tableName]);
useEffect(() => {
if (open) {
setSearchText("");
setActiveFilterTab(hasFilterTabs ? filterTabs![0] : null);
fetchData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, fetchData, hasFilterTabs]);
// 필터링된 행 계산
const filteredRows = useMemo(() => {
let items = allRows;
// 텍스트 검색 필터
if (searchText.trim()) {
const cols = searchColumns && searchColumns.length > 0 ? searchColumns : colsToShow;
items = items.filter((row) =>
cols.some((col) => {
const val = row[col];
return val != null && matchSearchMode(String(val), searchText, searchMode);
})
);
}
// 필터 탭 (초성/알파벳) 적용
if (activeFilterTab && displayField) {
items = items.filter((row) => {
const val = row[displayField];
if (val == null) return false;
const key = getGroupKey(String(val), activeFilterTab);
return key !== "#";
});
}
return items;
}, [allRows, searchText, searchColumns, colsToShow, searchMode, activeFilterTab, displayField]);
// 그룹화 (필터 탭 활성화 시)
const groupedRows = useMemo(() => {
if (!activeFilterTab || !displayField) return null;
const groups = new Map<string, Record<string, unknown>[]>();
for (const row of filteredRows) {
const val = row[displayField];
const key = val != null ? getGroupKey(String(val), activeFilterTab) : "#";
if (key === "#") continue;
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(row);
}
// 정렬
const sorted = [...groups.entries()].sort(([a], [b]) => a.localeCompare(b, "ko"));
return sorted;
}, [filteredRows, activeFilterTab, displayField]);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setSearchText(v);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {}, 300);
};
const getColLabel = (colName: string) => columnLabels?.[colName] || colName;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader className="flex flex-row items-center justify-between">
<DialogTitle className="text-base sm:text-lg">{title} </DialogTitle>
{/* 필터 탭 버튼 */}
{hasFilterTabs && (
<div className="flex gap-1">
{filterTabs!.map((tab) => (
<Button
key={tab}
variant={activeFilterTab === tab ? "default" : "outline"}
size="sm"
className="h-7 px-3 text-[11px]"
onClick={() => setActiveFilterTab(activeFilterTab === tab ? null : tab)}
>
{MODAL_FILTER_TAB_LABELS[tab]}
</Button>
))}
</div>
)}
</DialogHeader>
{/* 검색 입력 */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchText}
onChange={handleSearchChange}
placeholder="검색..."
className="h-9 pl-8 text-sm"
autoFocus
/>
{searchText && (
<button
type="button"
onClick={() => setSearchText("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* 결과 영역 */}
<div className="max-h-[50vh] overflow-auto rounded-md border">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : filteredRows.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{searchText ? "검색 결과가 없습니다" : "데이터가 없습니다"}
</div>
) : displayStyle === "icon" ? (
<IconView
rows={filteredRows}
groupedRows={groupedRows}
displayField={displayField || ""}
onSelect={onSelect}
/>
) : (
<TableView
rows={filteredRows}
groupedRows={groupedRows}
colsToShow={colsToShow}
displayField={displayField || ""}
getColLabel={getColLabel}
activeFilterTab={activeFilterTab}
onSelect={onSelect}
/>
)}
</div>
<p className="text-[10px] text-muted-foreground">
{filteredRows.length} / {displayStyle === "icon" ? "아이콘" : "행"}
</p>
</DialogContent>
</Dialog>
);
}
// ========================================
// 테이블 뷰
// ========================================
function TableView({
rows,
groupedRows,
colsToShow,
displayField,
getColLabel,
activeFilterTab,
onSelect,
}: {
rows: Record<string, unknown>[];
groupedRows: [string, Record<string, unknown>[]][] | null;
colsToShow: string[];
displayField: string;
getColLabel: (col: string) => string;
activeFilterTab: ModalFilterTab | null;
onSelect: (row: Record<string, unknown>) => void;
}) {
const renderRow = (row: Record<string, unknown>, i: number) => (
<tr key={i} className="cursor-pointer border-t transition-colors hover:bg-accent" onClick={() => onSelect(row)}>
{colsToShow.length > 0
? colsToShow.map((col) => (
<td key={col} className="px-3 py-2 text-xs">{String(row[col] ?? "")}</td>
))
: Object.entries(row).slice(0, 3).map(([k, v]) => (
<td key={k} className="px-3 py-2 text-xs">{String(v ?? "")}</td>
))}
</tr>
);
if (groupedRows && activeFilterTab) {
return (
<div>
{colsToShow.length > 0 && (
<div className="sticky top-0 z-10 flex bg-muted">
{colsToShow.map((col) => (
<div key={col} className="flex-1 px-3 py-2 text-xs font-medium text-muted-foreground">
{getColLabel(col)}
</div>
))}
</div>
)}
{groupedRows.map(([groupKey, groupRows]) => (
<div key={groupKey}>
<div className="sticky top-8 z-5 flex items-center gap-2 bg-background px-3 py-1.5">
<span className="text-sm font-semibold text-primary">{groupKey}</span>
<div className="h-px flex-1 bg-border" />
</div>
<table className="w-full text-sm">
<tbody>
{groupRows.map((row, i) => renderRow(row, i))}
</tbody>
</table>
</div>
))}
</div>
);
}
return (
<table className="w-full text-sm">
{colsToShow.length > 0 && (
<thead className="sticky top-0 bg-muted">
<tr>
{colsToShow.map((col) => (
<th key={col} className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
{getColLabel(col)}
</th>
))}
</tr>
</thead>
)}
<tbody>
{rows.map((row, i) => renderRow(row, i))}
</tbody>
</table>
);
}
// ========================================
// 아이콘 뷰
// ========================================
function IconView({
rows,
groupedRows,
displayField,
onSelect,
}: {
rows: Record<string, unknown>[];
groupedRows: [string, Record<string, unknown>[]][] | null;
displayField: string;
onSelect: (row: Record<string, unknown>) => void;
}) {
const renderIconCard = (row: Record<string, unknown>, i: number) => {
const text = displayField ? String(row[displayField] ?? "") : "";
const firstChar = text.charAt(0) || "?";
const color = getIconColor(text);
return (
<div
key={i}
className="flex w-20 cursor-pointer flex-col items-center gap-1.5 rounded-lg p-2 transition-colors hover:bg-accent"
onClick={() => onSelect(row)}
>
<div className={cn("flex h-14 w-14 items-center justify-center rounded-xl text-xl font-bold text-white", color)}>
{firstChar}
</div>
<span className="w-full truncate text-center text-[11px]">{text}</span>
</div>
);
};
if (groupedRows) {
return (
<div className="p-3">
{groupedRows.map(([groupKey, groupRows]) => (
<div key={groupKey} className="mb-4">
<div className="mb-2 flex items-center gap-2">
<span className="text-sm font-semibold text-primary">{groupKey}</span>
<div className="h-px flex-1 bg-border" />
</div>
<div className="flex flex-wrap gap-2">
{groupRows.map((row, i) => renderIconCard(row, i))}
</div>
</div>
))}
</div>
);
}
return (
<div className="flex flex-wrap gap-2 p-3">
{rows.map((row, i) => renderIconCard(row, i))}
</div>
);
}