1046 lines
34 KiB
TypeScript
1046 lines
34 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, CalendarDays } from "lucide-react";
|
|
import { Calendar } from "@/components/ui/calendar";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { format, startOfWeek, endOfWeek, startOfMonth, endOfMonth } from "date-fns";
|
|
import { ko } from "date-fns/locale";
|
|
import { usePopEvent } from "@/hooks/pop";
|
|
import { dataApi } from "@/lib/api/data";
|
|
import type {
|
|
PopSearchConfig,
|
|
DatePresetOption,
|
|
DateSelectionMode,
|
|
CalendarDisplayMode,
|
|
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 normalizedType = normalizeInputType(config.inputType as string);
|
|
const isModalType = normalizedType === "modal";
|
|
const fieldKey = isModalType
|
|
? (config.modalConfig?.valueField || config.fieldName || componentId || "search")
|
|
: (config.fieldName || componentId || "search");
|
|
|
|
const resolveFilterMode = useCallback(() => {
|
|
if (config.filterMode) return config.filterMode;
|
|
if (normalizedType === "date") {
|
|
const mode: DateSelectionMode = config.dateSelectionMode || "single";
|
|
return mode === "range" ? "range" : "equals";
|
|
}
|
|
return "contains";
|
|
}, [config.filterMode, config.dateSelectionMode, normalizedType]);
|
|
|
|
const emitFilterChanged = useCallback(
|
|
(newValue: unknown) => {
|
|
setValue(newValue);
|
|
setSharedData(`search_${fieldKey}`, newValue);
|
|
|
|
if (componentId) {
|
|
const filterColumns = config.filterColumns?.length ? config.filterColumns : [fieldKey];
|
|
publish(`__comp_output__${componentId}__filter_value`, {
|
|
fieldName: fieldKey,
|
|
filterColumns,
|
|
value: newValue,
|
|
filterMode: resolveFilterMode(),
|
|
});
|
|
}
|
|
|
|
publish("filter_changed", { [fieldKey]: newValue });
|
|
},
|
|
[fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!componentId) return;
|
|
const unsub = subscribe(
|
|
`__comp_input__${componentId}__set_value`,
|
|
(payload: unknown) => {
|
|
const data = payload as { value?: unknown; displayText?: string } | unknown;
|
|
const incoming = typeof data === "object" && data && "value" in data
|
|
? (data as { value: unknown }).value
|
|
: data;
|
|
if (isModalType && incoming != null) {
|
|
setModalDisplayText(String(incoming));
|
|
}
|
|
emitFilterChanged(incoming);
|
|
}
|
|
);
|
|
return unsub;
|
|
}, [componentId, subscribe, emitFilterChanged, isModalType]);
|
|
|
|
useEffect(() => {
|
|
const unsub = subscribe("scan_auto_fill", (payload: unknown) => {
|
|
const data = payload as Record<string, unknown> | null;
|
|
if (!data || typeof data !== "object") return;
|
|
const myKey = config.fieldName;
|
|
if (!myKey) return;
|
|
const targetKeys = config.filterColumns?.length ? config.filterColumns : [myKey];
|
|
for (const key of targetKeys) {
|
|
if (key in data) {
|
|
if (isModalType) setModalDisplayText(String(data[key]));
|
|
emitFilterChanged(data[key]);
|
|
return;
|
|
}
|
|
}
|
|
if (myKey in data) {
|
|
if (isModalType) setModalDisplayText(String(data[myKey]));
|
|
emitFilterChanged(data[myKey]);
|
|
}
|
|
});
|
|
return unsub;
|
|
}, [subscribe, emitFilterChanged, config.fieldName, config.filterColumns, isModalType]);
|
|
|
|
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 handleModalClear = useCallback(() => {
|
|
setModalDisplayText("");
|
|
emitFilterChanged("");
|
|
}, [emitFilterChanged]);
|
|
|
|
const showLabel = config.labelVisible !== false && !!config.labelText;
|
|
|
|
return (
|
|
<div
|
|
className="flex h-full w-full flex-col items-center justify-center gap-0.5 overflow-hidden p-1.5"
|
|
>
|
|
{showLabel && (
|
|
<span className="w-full shrink-0 truncate text-[10px] font-medium text-muted-foreground">
|
|
{config.labelText}
|
|
</span>
|
|
)}
|
|
<div className="min-w-0 w-full flex-1 flex flex-col justify-center">
|
|
<SearchInputRenderer
|
|
config={config}
|
|
value={value}
|
|
onChange={emitFilterChanged}
|
|
modalDisplayText={modalDisplayText}
|
|
onModalOpen={handleModalOpen}
|
|
onModalClear={handleModalClear}
|
|
/>
|
|
</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;
|
|
onModalClear?: () => void;
|
|
}
|
|
|
|
function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear }: 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": {
|
|
const dateMode: DateSelectionMode = config.dateSelectionMode || "single";
|
|
return dateMode === "range"
|
|
? <DateRangeInput config={config} value={value} onChange={onChange} />
|
|
: <DateSingleInput 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} onClear={onModalClear} />;
|
|
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 h-full">
|
|
<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-full min-h-8 pl-7 text-xs"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// date 서브타입 - 단일 날짜
|
|
// ========================================
|
|
|
|
function DateSingleInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) {
|
|
const [open, setOpen] = useState(false);
|
|
const useModal = config.calendarDisplay === "modal";
|
|
const selected = value ? new Date(value + "T00:00:00") : undefined;
|
|
|
|
const handleSelect = useCallback(
|
|
(day: Date | undefined) => {
|
|
if (!day) return;
|
|
onChange(format(day, "yyyy-MM-dd"));
|
|
setOpen(false);
|
|
},
|
|
[onChange]
|
|
);
|
|
|
|
const handleClear = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onChange("");
|
|
},
|
|
[onChange]
|
|
);
|
|
|
|
const triggerButton = (
|
|
<Button
|
|
variant="outline"
|
|
className={cn(
|
|
"h-full min-h-8 w-full justify-start gap-1.5 px-2 text-xs font-normal",
|
|
!value && "text-muted-foreground"
|
|
)}
|
|
onClick={useModal ? () => setOpen(true) : undefined}
|
|
>
|
|
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="flex-1 truncate text-left">
|
|
{value ? format(new Date(value + "T00:00:00"), "yyyy.MM.dd (EEE)", { locale: ko }) : (config.placeholder || "날짜 선택")}
|
|
</span>
|
|
{value && (
|
|
<span
|
|
role="button"
|
|
tabIndex={-1}
|
|
onClick={handleClear}
|
|
onKeyDown={(e) => { if (e.key === "Enter") handleClear(e as unknown as React.MouseEvent); }}
|
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</span>
|
|
)}
|
|
</Button>
|
|
);
|
|
|
|
if (useModal) {
|
|
return (
|
|
<>
|
|
{triggerButton}
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[360px] p-0">
|
|
<DialogHeader className="px-4 pt-4 pb-0">
|
|
<DialogTitle className="text-sm">날짜 선택</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="flex justify-center pb-4">
|
|
<Calendar
|
|
mode="single"
|
|
selected={selected}
|
|
onSelect={handleSelect}
|
|
locale={ko}
|
|
defaultMonth={selected || new Date()}
|
|
className="touch-date-calendar"
|
|
/>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
{triggerButton}
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-0" align="start">
|
|
<Calendar
|
|
mode="single"
|
|
selected={selected}
|
|
onSelect={handleSelect}
|
|
locale={ko}
|
|
defaultMonth={selected || new Date()}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// date 서브타입 - 기간 선택 (프리셋 + Calendar Range)
|
|
// ========================================
|
|
|
|
interface DateRangeValue { from?: string; to?: string }
|
|
|
|
const RANGE_PRESETS = [
|
|
{ key: "today", label: "오늘" },
|
|
{ key: "this-week", label: "이번주" },
|
|
{ key: "this-month", label: "이번달" },
|
|
] as const;
|
|
|
|
function computeRangePreset(key: string): DateRangeValue {
|
|
const now = new Date();
|
|
const fmt = (d: Date) => format(d, "yyyy-MM-dd");
|
|
switch (key) {
|
|
case "today":
|
|
return { from: fmt(now), to: fmt(now) };
|
|
case "this-week":
|
|
return { from: fmt(startOfWeek(now, { weekStartsOn: 1 })), to: fmt(endOfWeek(now, { weekStartsOn: 1 })) };
|
|
case "this-month":
|
|
return { from: fmt(startOfMonth(now)), to: fmt(endOfMonth(now)) };
|
|
default:
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function DateRangeInput({ config, value, onChange }: { config: PopSearchConfig; value: unknown; onChange: (v: unknown) => void }) {
|
|
const [open, setOpen] = useState(false);
|
|
const useModal = config.calendarDisplay === "modal";
|
|
|
|
const rangeVal: DateRangeValue = (typeof value === "object" && value !== null)
|
|
? value as DateRangeValue
|
|
: (typeof value === "string" && value ? { from: value, to: value } : {});
|
|
|
|
const calendarRange = useMemo(() => {
|
|
if (!rangeVal.from) return undefined;
|
|
return {
|
|
from: new Date(rangeVal.from + "T00:00:00"),
|
|
to: rangeVal.to ? new Date(rangeVal.to + "T00:00:00") : undefined,
|
|
};
|
|
}, [rangeVal.from, rangeVal.to]);
|
|
|
|
const activePreset = RANGE_PRESETS.find((p) => {
|
|
const preset = computeRangePreset(p.key);
|
|
return preset.from === rangeVal.from && preset.to === rangeVal.to;
|
|
})?.key ?? null;
|
|
|
|
const handlePreset = useCallback(
|
|
(key: string) => {
|
|
const preset = computeRangePreset(key);
|
|
onChange(preset);
|
|
},
|
|
[onChange]
|
|
);
|
|
|
|
const handleRangeSelect = useCallback(
|
|
(range: { from?: Date; to?: Date } | undefined) => {
|
|
if (!range?.from) return;
|
|
const from = format(range.from, "yyyy-MM-dd");
|
|
const to = range.to ? format(range.to, "yyyy-MM-dd") : from;
|
|
onChange({ from, to });
|
|
if (range.to) setOpen(false);
|
|
},
|
|
[onChange]
|
|
);
|
|
|
|
const handleClear = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onChange({});
|
|
},
|
|
[onChange]
|
|
);
|
|
|
|
const displayText = rangeVal.from
|
|
? rangeVal.from === rangeVal.to
|
|
? format(new Date(rangeVal.from + "T00:00:00"), "MM/dd (EEE)", { locale: ko })
|
|
: `${format(new Date(rangeVal.from + "T00:00:00"), "MM/dd", { locale: ko })} ~ ${rangeVal.to ? format(new Date(rangeVal.to + "T00:00:00"), "MM/dd", { locale: ko }) : ""}`
|
|
: "";
|
|
|
|
const presetBar = (
|
|
<div className="flex gap-1 px-3">
|
|
{RANGE_PRESETS.map((p) => (
|
|
<Button
|
|
key={p.key}
|
|
variant={activePreset === p.key ? "default" : "outline"}
|
|
size="sm"
|
|
className="h-7 flex-1 px-1 text-[10px]"
|
|
onClick={() => {
|
|
handlePreset(p.key);
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
{p.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
const calendarEl = (
|
|
<Calendar
|
|
mode="range"
|
|
selected={calendarRange}
|
|
onSelect={handleRangeSelect}
|
|
locale={ko}
|
|
defaultMonth={calendarRange?.from || new Date()}
|
|
numberOfMonths={1}
|
|
className={useModal ? "touch-date-calendar" : undefined}
|
|
/>
|
|
);
|
|
|
|
const triggerButton = (
|
|
<Button
|
|
variant="outline"
|
|
className={cn(
|
|
"h-full min-h-8 w-full justify-start gap-1.5 px-2 text-xs font-normal",
|
|
!rangeVal.from && "text-muted-foreground"
|
|
)}
|
|
onClick={useModal ? () => setOpen(true) : undefined}
|
|
>
|
|
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="flex-1 truncate text-left">
|
|
{displayText || (config.placeholder || "기간 선택")}
|
|
</span>
|
|
{rangeVal.from && (
|
|
<span
|
|
role="button"
|
|
tabIndex={-1}
|
|
onClick={handleClear}
|
|
onKeyDown={(e) => { if (e.key === "Enter") handleClear(e as unknown as React.MouseEvent); }}
|
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</span>
|
|
)}
|
|
</Button>
|
|
);
|
|
|
|
if (useModal) {
|
|
return (
|
|
<>
|
|
{triggerButton}
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[360px] p-0">
|
|
<DialogHeader className="px-4 pt-4 pb-0">
|
|
<DialogTitle className="text-sm">기간 선택</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-2 pb-4">
|
|
{presetBar}
|
|
<div className="flex justify-center">
|
|
{calendarEl}
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
{triggerButton}
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-0" align="start">
|
|
<div className="space-y-2 pt-2 pb-1">
|
|
{presetBar}
|
|
{calendarEl}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// 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-full min-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 h-full flex-wrap items-center 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 h-full 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, onClear }: { config: PopSearchConfig; displayText: string; onClick?: () => void; onClear?: () => void }) {
|
|
const hasValue = !!displayText;
|
|
|
|
return (
|
|
<div
|
|
className="flex h-full min-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 ${hasValue ? "" : "text-muted-foreground"}`}>
|
|
{displayText || config.placeholder || "선택..."}
|
|
</span>
|
|
{hasValue && onClear ? (
|
|
<button
|
|
type="button"
|
|
className="ml-1 shrink-0 rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
|
onClick={(e) => { e.stopPropagation(); onClear(); }}
|
|
aria-label="선택 해제"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
) : (
|
|
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// 미구현 서브타입 플레이스홀더
|
|
// ========================================
|
|
|
|
function PlaceholderInput({ inputType }: { inputType: string }) {
|
|
return (
|
|
<div className="flex h-full min-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,
|
|
distinct,
|
|
} = 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 });
|
|
let rows = result.data || [];
|
|
|
|
if (distinct && displayField) {
|
|
const seen = new Set<string>();
|
|
rows = rows.filter((row) => {
|
|
const val = String(row[displayField] ?? "");
|
|
if (seen.has(val)) return false;
|
|
seen.add(val);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
setAllRows(rows);
|
|
} catch {
|
|
setAllRows([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [tableName, distinct, displayField]);
|
|
|
|
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>
|
|
);
|
|
}
|