feat(pop-search): 날짜 입력 타입 구현 + 셀 반응형 레이아웃 개선
pop-search 컴포넌트의 date 입력 타입이 미구현 상태에서 네이티브 input으로
임시 처리되어 있던 것을 shadcn Calendar 기반 POP 전용 UI로 교체하고,
셀 크기에 입력 필드가 반응하지 않던 레이아웃 문제를 함께 해결한다.
[BLOCK P: 날짜 입력 타입]
- DateSingleInput: Calendar + Dialog/Popover 기반 단일 날짜 선택
- DateRangeInput: 프리셋(오늘/이번주/이번달) + Calendar 기간 선택
- CalendarDisplayMode(popover/modal) 설정으로 터치 환경 대응
- resolveFilterMode로 date->equals, range->range 자동 결정
- DateDetailSettings 설정 패널 추가 (모드 선택 + 캘린더 표시 방식)
- PopStringListComponent: range 필터 모드 구현 + 날짜 equals 비교 개선
[레이아웃 개선]
- 입력 필드가 셀 너비/높이에 반응하도록 h-full min-h-8 + w-full 적용
- labelPosition("top"/"left") 분기 제거 -> 항상 라벨 위 고정
- 설정 패널에서 "라벨 위치" Select UI 제거
- 기본 배치 크기 colSpan:4 rowSpan:2 -> colSpan:2 rowSpan:1
This commit is contained in:
parent
47384e1c2b
commit
297b14d706
|
|
@ -360,7 +360,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 },
|
"pop-search": { colSpan: 2, rowSpan: 1 },
|
||||||
"pop-field": { colSpan: 6, rowSpan: 2 },
|
"pop-field": { colSpan: 6, rowSpan: 2 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,22 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Search, ChevronRight, Loader2, X } from "lucide-react";
|
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 { usePopEvent } from "@/hooks/pop";
|
||||||
import { dataApi } from "@/lib/api/data";
|
import { dataApi } from "@/lib/api/data";
|
||||||
import type {
|
import type {
|
||||||
PopSearchConfig,
|
PopSearchConfig,
|
||||||
DatePresetOption,
|
DatePresetOption,
|
||||||
|
DateSelectionMode,
|
||||||
|
CalendarDisplayMode,
|
||||||
ModalSelectConfig,
|
ModalSelectConfig,
|
||||||
ModalSearchMode,
|
ModalSearchMode,
|
||||||
ModalFilterTab,
|
ModalFilterTab,
|
||||||
|
|
@ -68,6 +78,15 @@ export function PopSearchComponent({
|
||||||
? (config.modalConfig?.valueField || config.fieldName || componentId || "search")
|
? (config.modalConfig?.valueField || config.fieldName || componentId || "search")
|
||||||
: (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(
|
const emitFilterChanged = useCallback(
|
||||||
(newValue: unknown) => {
|
(newValue: unknown) => {
|
||||||
setValue(newValue);
|
setValue(newValue);
|
||||||
|
|
@ -79,13 +98,13 @@ export function PopSearchComponent({
|
||||||
fieldName: fieldKey,
|
fieldName: fieldKey,
|
||||||
filterColumns,
|
filterColumns,
|
||||||
value: newValue,
|
value: newValue,
|
||||||
filterMode: config.filterMode || "contains",
|
filterMode: resolveFilterMode(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
publish("filter_changed", { [fieldKey]: newValue });
|
publish("filter_changed", { [fieldKey]: newValue });
|
||||||
},
|
},
|
||||||
[fieldKey, publish, setSharedData, componentId, config.filterMode, config.filterColumns]
|
[fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -125,19 +144,14 @@ export function PopSearchComponent({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className="flex h-full w-full flex-col items-center justify-center gap-0.5 overflow-hidden p-1.5"
|
||||||
"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 && (
|
{showLabel && (
|
||||||
<span className="shrink-0 truncate text-[10px] font-medium text-muted-foreground">
|
<span className="w-full shrink-0 truncate text-[10px] font-medium text-muted-foreground">
|
||||||
{config.labelText}
|
{config.labelText}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 w-full flex-1 flex flex-col justify-center">
|
||||||
<SearchInputRenderer
|
<SearchInputRenderer
|
||||||
config={config}
|
config={config}
|
||||||
value={value}
|
value={value}
|
||||||
|
|
@ -180,6 +194,12 @@ function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModa
|
||||||
return <TextSearchInput config={config} value={String(value ?? "")} onChange={onChange} />;
|
return <TextSearchInput config={config} value={String(value ?? "")} onChange={onChange} />;
|
||||||
case "select":
|
case "select":
|
||||||
return <SelectSearchInput config={config} value={String(value ?? "")} onChange={onChange} />;
|
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":
|
case "date-preset":
|
||||||
return <DatePresetSearchInput config={config} value={value} onChange={onChange} />;
|
return <DatePresetSearchInput config={config} value={value} onChange={onChange} />;
|
||||||
case "toggle":
|
case "toggle":
|
||||||
|
|
@ -220,7 +240,7 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig;
|
||||||
const isNumber = config.inputType === "number";
|
const isNumber = config.inputType === "number";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<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" />
|
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
type={isNumber ? "number" : "text"}
|
type={isNumber ? "number" : "text"}
|
||||||
|
|
@ -229,12 +249,283 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig;
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={config.placeholder || (isNumber ? "숫자 입력" : "검색어 입력")}
|
placeholder={config.placeholder || (isNumber ? "숫자 입력" : "검색어 입력")}
|
||||||
className="h-8 pl-7 text-xs"
|
className="h-full min-h-8 pl-7 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 서브타입
|
// select 서브타입
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -242,7 +533,7 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig;
|
||||||
function SelectSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) {
|
function SelectSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) {
|
||||||
return (
|
return (
|
||||||
<Select value={value || undefined} onValueChange={(v) => onChange(v)}>
|
<Select value={value || undefined} onValueChange={(v) => onChange(v)}>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-full min-h-8 text-xs">
|
||||||
<SelectValue placeholder={config.placeholder || "선택"} />
|
<SelectValue placeholder={config.placeholder || "선택"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -271,7 +562,7 @@ function DatePresetSearchInput({ config, value, onChange }: { config: PopSearchC
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex h-full flex-wrap items-center gap-1">
|
||||||
{presets.map((preset) => (
|
{presets.map((preset) => (
|
||||||
<Button key={preset} variant={currentPreset === preset ? "default" : "outline"} size="sm" className="h-7 px-2 text-[10px]" onClick={() => handleSelect(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]}
|
{DATE_PRESET_LABELS[preset]}
|
||||||
|
|
@ -287,7 +578,7 @@ function DatePresetSearchInput({ config, value, onChange }: { config: PopSearchC
|
||||||
|
|
||||||
function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v: unknown) => void }) {
|
function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v: unknown) => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex h-full items-center gap-2">
|
||||||
<Switch checked={value} onCheckedChange={(checked) => onChange(checked)} />
|
<Switch checked={value} onCheckedChange={(checked) => onChange(checked)} />
|
||||||
<span className="text-xs text-muted-foreground">{value ? "ON" : "OFF"}</span>
|
<span className="text-xs text-muted-foreground">{value ? "ON" : "OFF"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -301,7 +592,7 @@ function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v:
|
||||||
function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchConfig; displayText: string; onClick?: () => void }) {
|
function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchConfig; displayText: string; onClick?: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-8 cursor-pointer items-center rounded-md border border-input bg-background px-3 transition-colors hover:bg-accent"
|
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"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
|
@ -319,7 +610,7 @@ function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchC
|
||||||
|
|
||||||
function PlaceholderInput({ inputType }: { inputType: string }) {
|
function PlaceholderInput({ inputType }: { inputType: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-8 items-center rounded-md border border-dashed border-muted-foreground/30 px-3">
|
<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>
|
<span className="text-[10px] text-muted-foreground">{inputType} (후속 구현 예정)</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ import type {
|
||||||
PopSearchConfig,
|
PopSearchConfig,
|
||||||
SearchInputType,
|
SearchInputType,
|
||||||
SearchFilterMode,
|
SearchFilterMode,
|
||||||
|
DateSelectionMode,
|
||||||
|
CalendarDisplayMode,
|
||||||
DatePresetOption,
|
DatePresetOption,
|
||||||
ModalSelectConfig,
|
ModalSelectConfig,
|
||||||
ModalDisplayStyle,
|
ModalDisplayStyle,
|
||||||
|
|
@ -59,7 +61,6 @@ const DEFAULT_CONFIG: PopSearchConfig = {
|
||||||
placeholder: "검색어 입력",
|
placeholder: "검색어 입력",
|
||||||
debounceMs: 500,
|
debounceMs: 500,
|
||||||
triggerOnEnter: true,
|
triggerOnEnter: true,
|
||||||
labelPosition: "top",
|
|
||||||
labelText: "",
|
labelText: "",
|
||||||
labelVisible: true,
|
labelVisible: true,
|
||||||
};
|
};
|
||||||
|
|
@ -197,32 +198,15 @@ function StepBasicSettings({ cfg, update }: StepProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cfg.labelVisible !== false && (
|
{cfg.labelVisible !== false && (
|
||||||
<>
|
<div className="space-y-1">
|
||||||
<div className="space-y-1">
|
<Label className="text-[10px]">라벨 텍스트</Label>
|
||||||
<Label className="text-[10px]">라벨 텍스트</Label>
|
<Input
|
||||||
<Input
|
value={cfg.labelText || ""}
|
||||||
value={cfg.labelText || ""}
|
onChange={(e) => update({ labelText: e.target.value })}
|
||||||
onChange={(e) => update({ labelText: e.target.value })}
|
placeholder="예: 거래처명"
|
||||||
placeholder="예: 거래처명"
|
className="h-8 text-xs"
|
||||||
className="h-8 text-xs"
|
/>
|
||||||
/>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
@ -241,6 +225,8 @@ function StepDetailSettings({ cfg, update, allComponents, connections, component
|
||||||
return <TextDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
|
return <TextDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
|
||||||
case "select":
|
case "select":
|
||||||
return <SelectDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
|
return <SelectDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
|
||||||
|
case "date":
|
||||||
|
return <DateDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
|
||||||
case "date-preset":
|
case "date-preset":
|
||||||
return <DatePresetDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
|
return <DatePresetDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
|
||||||
case "modal":
|
case "modal":
|
||||||
|
|
@ -613,6 +599,88 @@ function SelectDetailSettings({ cfg, update, allComponents, connections, compone
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// date 상세 설정
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
const DATE_SELECTION_MODE_LABELS: Record<DateSelectionMode, string> = {
|
||||||
|
single: "단일 날짜",
|
||||||
|
range: "기간 선택",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CALENDAR_DISPLAY_LABELS: Record<CalendarDisplayMode, string> = {
|
||||||
|
popover: "팝오버 (PC용)",
|
||||||
|
modal: "모달 (터치/POP용)",
|
||||||
|
};
|
||||||
|
|
||||||
|
function DateDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
|
||||||
|
const mode: DateSelectionMode = cfg.dateSelectionMode || "single";
|
||||||
|
const calDisplay: CalendarDisplayMode = cfg.calendarDisplay || "modal";
|
||||||
|
const autoFilterMode = mode === "range" ? "range" : "equals";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">날짜 선택 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={mode}
|
||||||
|
onValueChange={(v) => update({ dateSelectionMode: v as DateSelectionMode })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(DATE_SELECTION_MODE_LABELS).map(([key, label]) => (
|
||||||
|
<SelectItem key={key} value={key} className="text-xs">
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[9px] text-muted-foreground">
|
||||||
|
{mode === "single"
|
||||||
|
? "캘린더에서 날짜 하나를 선택합니다"
|
||||||
|
: "프리셋(오늘/이번주/이번달) + 캘린더 기간 선택"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">캘린더 표시 방식</Label>
|
||||||
|
<Select
|
||||||
|
value={calDisplay}
|
||||||
|
onValueChange={(v) => update({ calendarDisplay: v as CalendarDisplayMode })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(CALENDAR_DISPLAY_LABELS).map(([key, label]) => (
|
||||||
|
<SelectItem key={key} value={key} className="text-xs">
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[9px] text-muted-foreground">
|
||||||
|
{calDisplay === "modal"
|
||||||
|
? "터치 친화적인 큰 모달로 캘린더가 열립니다"
|
||||||
|
: "입력란 아래에 작은 팝오버로 열립니다"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FilterConnectionSection
|
||||||
|
cfg={cfg}
|
||||||
|
update={update}
|
||||||
|
showFieldName
|
||||||
|
fixedFilterMode={autoFilterMode}
|
||||||
|
allComponents={allComponents}
|
||||||
|
connections={connections}
|
||||||
|
componentId={componentId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// date-preset 상세 설정
|
// date-preset 상세 설정
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@ export function normalizeInputType(t: string): SearchInputType {
|
||||||
return t as SearchInputType;
|
return t as SearchInputType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 날짜 선택 모드 */
|
||||||
|
export type DateSelectionMode = "single" | "range";
|
||||||
|
|
||||||
|
/** 캘린더 표시 방식 (POP 터치 환경에서는 modal 권장) */
|
||||||
|
export type CalendarDisplayMode = "popover" | "modal";
|
||||||
|
|
||||||
/** 날짜 프리셋 옵션 */
|
/** 날짜 프리셋 옵션 */
|
||||||
export type DatePresetOption = "today" | "this-week" | "this-month" | "custom";
|
export type DatePresetOption = "today" | "this-week" | "this-month" | "custom";
|
||||||
|
|
||||||
|
|
@ -84,6 +90,10 @@ export interface PopSearchConfig {
|
||||||
options?: SelectOption[];
|
options?: SelectOption[];
|
||||||
optionsDataSource?: SelectDataSource;
|
optionsDataSource?: SelectDataSource;
|
||||||
|
|
||||||
|
// date 전용
|
||||||
|
dateSelectionMode?: DateSelectionMode;
|
||||||
|
calendarDisplay?: CalendarDisplayMode;
|
||||||
|
|
||||||
// date-preset 전용
|
// date-preset 전용
|
||||||
datePresets?: DatePresetOption[];
|
datePresets?: DatePresetOption[];
|
||||||
|
|
||||||
|
|
@ -94,9 +104,6 @@ export interface PopSearchConfig {
|
||||||
labelText?: string;
|
labelText?: string;
|
||||||
labelVisible?: boolean;
|
labelVisible?: boolean;
|
||||||
|
|
||||||
// 스타일
|
|
||||||
labelPosition?: "top" | "left";
|
|
||||||
|
|
||||||
// 연결된 리스트에 필터를 보낼 때의 매칭 방식
|
// 연결된 리스트에 필터를 보낼 때의 매칭 방식
|
||||||
filterMode?: SearchFilterMode;
|
filterMode?: SearchFilterMode;
|
||||||
|
|
||||||
|
|
@ -111,7 +118,6 @@ export const DEFAULT_SEARCH_CONFIG: PopSearchConfig = {
|
||||||
placeholder: "검색어 입력",
|
placeholder: "검색어 입력",
|
||||||
debounceMs: 500,
|
debounceMs: 500,
|
||||||
triggerOnEnter: true,
|
triggerOnEnter: true,
|
||||||
labelPosition: "top",
|
|
||||||
labelText: "",
|
labelText: "",
|
||||||
labelVisible: true,
|
labelVisible: true,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -192,10 +192,9 @@ export function PopStringListComponent({
|
||||||
row: RowData,
|
row: RowData,
|
||||||
filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } }
|
filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } }
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const searchValue = String(filter.value).toLowerCase();
|
|
||||||
if (!searchValue) return true;
|
|
||||||
|
|
||||||
const fc = filter.filterConfig;
|
const fc = filter.filterConfig;
|
||||||
|
const mode = fc?.filterMode || "contains";
|
||||||
|
|
||||||
const columns: string[] =
|
const columns: string[] =
|
||||||
fc?.targetColumns?.length
|
fc?.targetColumns?.length
|
||||||
? fc.targetColumns
|
? fc.targetColumns
|
||||||
|
|
@ -207,17 +206,46 @@ export function PopStringListComponent({
|
||||||
|
|
||||||
if (columns.length === 0) return true;
|
if (columns.length === 0) return true;
|
||||||
|
|
||||||
const mode = fc?.filterMode || "contains";
|
// range 모드: { from, to } 객체 또는 단일 날짜 문자열 지원
|
||||||
|
if (mode === "range") {
|
||||||
|
const val = filter.value as { from?: string; to?: string } | string;
|
||||||
|
let from = "";
|
||||||
|
let to = "";
|
||||||
|
if (typeof val === "object" && val !== null) {
|
||||||
|
from = val.from || "";
|
||||||
|
to = val.to || "";
|
||||||
|
} else {
|
||||||
|
from = String(val || "");
|
||||||
|
to = from;
|
||||||
|
}
|
||||||
|
if (!from && !to) return true;
|
||||||
|
|
||||||
|
return columns.some((col) => {
|
||||||
|
const cellDate = String(row[col] ?? "").slice(0, 10);
|
||||||
|
if (!cellDate) return false;
|
||||||
|
if (from && cellDate < from) return false;
|
||||||
|
if (to && cellDate > to) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 문자열 기반 필터 (contains, equals, starts_with)
|
||||||
|
const searchValue = String(filter.value ?? "").toLowerCase();
|
||||||
|
if (!searchValue) return true;
|
||||||
|
|
||||||
|
// 날짜 패턴 감지 (YYYY-MM-DD): equals 비교 시 ISO 타임스탬프에서 날짜만 추출
|
||||||
|
const isDateValue = /^\d{4}-\d{2}-\d{2}$/.test(searchValue);
|
||||||
|
|
||||||
const matchCell = (cellValue: string) => {
|
const matchCell = (cellValue: string) => {
|
||||||
|
const target = isDateValue && mode === "equals" ? cellValue.slice(0, 10) : cellValue;
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case "equals":
|
case "equals":
|
||||||
return cellValue === searchValue;
|
return target === searchValue;
|
||||||
case "starts_with":
|
case "starts_with":
|
||||||
return cellValue.startsWith(searchValue);
|
return target.startsWith(searchValue);
|
||||||
case "contains":
|
case "contains":
|
||||||
default:
|
default:
|
||||||
return cellValue.includes(searchValue);
|
return target.includes(searchValue);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue