From 297b14d706e4660a54b170254d7de33aa73c78be Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 6 Mar 2026 12:22:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop-search):=20=EB=82=A0=EC=A7=9C=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=ED=83=80=EC=9E=85=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?+=20=EC=85=80=20=EB=B0=98=EC=9D=91=ED=98=95=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EA=B0=9C=EC=84=A0=20pop-search?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=9D=98=20date=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=ED=83=80=EC=9E=85=EC=9D=B4=20=EB=AF=B8?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EC=83=81=ED=83=9C=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=ED=8B=B0=EB=B8=8C=20input=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=9E=84=EC=8B=9C=20=EC=B2=98=EB=A6=AC=EB=90=98=EC=96=B4=20?= =?UTF-8?q?=EC=9E=88=EB=8D=98=20=EA=B2=83=EC=9D=84=20shadcn=20Calendar=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20POP=20=EC=A0=84=EC=9A=A9=20UI=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4=ED=95=98=EA=B3=A0,=20=EC=85=80=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=EC=97=90=20=EC=9E=85=EB=A0=A5=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=EA=B0=80=20=EB=B0=98=EC=9D=91=ED=95=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8D=98=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=EB=A5=BC=20=ED=95=A8=EA=BB=98=20=ED=95=B4=EA=B2=B0?= =?UTF-8?q?=ED=95=9C=EB=8B=A4.=20[BLOCK=20P:=20=EB=82=A0=EC=A7=9C=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=ED=83=80=EC=9E=85]=20-=20DateSingleInput:?= =?UTF-8?q?=20Calendar=20+=20Dialog/Popover=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EB=8B=A8=EC=9D=BC=20=EB=82=A0=EC=A7=9C=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?-=20DateRangeInput:=20=ED=94=84=EB=A6=AC=EC=85=8B(=EC=98=A4?= =?UTF-8?q?=EB=8A=98/=EC=9D=B4=EB=B2=88=EC=A3=BC/=EC=9D=B4=EB=B2=88?= =?UTF-8?q?=EB=8B=AC)=20+=20Calendar=20=EA=B8=B0=EA=B0=84=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20-=20CalendarDisplayMode(popover/modal)=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=9C=BC=EB=A1=9C=20=ED=84=B0=EC=B9=98=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EB=8C=80=EC=9D=91=20-=20resolveFilterMode=EB=A1=9C?= =?UTF-8?q?=20date->equals,=20range->range=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=95=20-=20DateDetailSettings=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=8C=A8=EB=84=90=20=EC=B6=94=EA=B0=80=20(?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EC=84=A0=ED=83=9D=20+=20=EC=BA=98?= =?UTF-8?q?=EB=A6=B0=EB=8D=94=20=ED=91=9C=EC=8B=9C=20=EB=B0=A9=EC=8B=9D)?= =?UTF-8?q?=20-=20PopStringListComponent:=20range=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EA=B5=AC=ED=98=84=20+=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20equals=20=EB=B9=84=EA=B5=90=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?[=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EA=B0=9C=EC=84=A0]=20-?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5=20=ED=95=84=EB=93=9C=EA=B0=80=20=EC=85=80?= =?UTF-8?q?=20=EB=84=88=EB=B9=84/=EB=86=92=EC=9D=B4=EC=97=90=20=EB=B0=98?= =?UTF-8?q?=EC=9D=91=ED=95=98=EB=8F=84=EB=A1=9D=20h-full=20min-h-8=20+=20w?= =?UTF-8?q?-full=20=EC=A0=81=EC=9A=A9=20-=20labelPosition("top"/"left")=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20=EC=A0=9C=EA=B1=B0=20->=20=ED=95=AD?= =?UTF-8?q?=EC=83=81=20=EB=9D=BC=EB=B2=A8=20=EC=9C=84=20=EA=B3=A0=EC=A0=95?= =?UTF-8?q?=20-=20=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90=EC=97=90?= =?UTF-8?q?=EC=84=9C=20"=EB=9D=BC=EB=B2=A8=20=EC=9C=84=EC=B9=98"=20Select?= =?UTF-8?q?=20UI=20=EC=A0=9C=EA=B1=B0=20-=20=EA=B8=B0=EB=B3=B8=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=ED=81=AC=EA=B8=B0=20colSpan:4=20rowSpan:2=20->=20c?= =?UTF-8?q?olSpan:2=20rowSpan:1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pop/designer/types/pop-layout.ts | 2 +- .../pop-search/PopSearchComponent.tsx | 327 +++++++++++++++++- .../pop-search/PopSearchConfig.tsx | 122 +++++-- .../pop-components/pop-search/types.ts | 14 +- .../PopStringListComponent.tsx | 42 ++- 5 files changed, 450 insertions(+), 57 deletions(-) diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index a02bf02b..faead048 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -360,7 +360,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record { + 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); @@ -79,13 +98,13 @@ export function PopSearchComponent({ fieldName: fieldKey, filterColumns, value: newValue, - filterMode: config.filterMode || "contains", + filterMode: resolveFilterMode(), }); } publish("filter_changed", { [fieldKey]: newValue }); }, - [fieldKey, publish, setSharedData, componentId, config.filterMode, config.filterColumns] + [fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns] ); useEffect(() => { @@ -125,19 +144,14 @@ export function PopSearchComponent({ return (
{showLabel && ( - + {config.labelText} )} -
+
; case "select": return ; + case "date": { + const dateMode: DateSelectionMode = config.dateSelectionMode || "single"; + return dateMode === "range" + ? + : ; + } case "date-preset": return ; case "toggle": @@ -220,7 +240,7 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig; const isNumber = config.inputType === "number"; return ( -
+
); } +// ======================================== +// 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 = ( + + ); + + if (useModal) { + return ( + <> + {triggerButton} + + + + 날짜 선택 + +
+ +
+
+
+ + ); + } + + return ( + + + {triggerButton} + + + + + + ); +} + +// ======================================== +// 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 = ( +
+ {RANGE_PRESETS.map((p) => ( + + ))} +
+ ); + + const calendarEl = ( + + ); + + const triggerButton = ( + + ); + + if (useModal) { + return ( + <> + {triggerButton} + + + + 기간 선택 + +
+ {presetBar} +
+ {calendarEl} +
+
+
+
+ + ); + } + + return ( + + + {triggerButton} + + +
+ {presetBar} + {calendarEl} +
+
+
+ ); +} + // ======================================== // 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 }) { return ( update({ labelText: e.target.value })} - placeholder="예: 거래처명" - className="h-8 text-xs" - /> -
-
- - -
- +
+ + update({ labelText: e.target.value })} + placeholder="예: 거래처명" + className="h-8 text-xs" + /> +
)}
@@ -241,6 +225,8 @@ function StepDetailSettings({ cfg, update, allComponents, connections, component return ; case "select": return ; + case "date": + return ; case "date-preset": return ; case "modal": @@ -613,6 +599,88 @@ function SelectDetailSettings({ cfg, update, allComponents, connections, compone ); } +// ======================================== +// date 상세 설정 +// ======================================== + +const DATE_SELECTION_MODE_LABELS: Record = { + single: "단일 날짜", + range: "기간 선택", +}; + +const CALENDAR_DISPLAY_LABELS: Record = { + 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 ( +
+
+ + +

+ {mode === "single" + ? "캘린더에서 날짜 하나를 선택합니다" + : "프리셋(오늘/이번주/이번달) + 캘린더 기간 선택"} +

+
+ +
+ + +

+ {calDisplay === "modal" + ? "터치 친화적인 큰 모달로 캘린더가 열립니다" + : "입력란 아래에 작은 팝오버로 열립니다"} +

+
+ + +
+ ); +} + // ======================================== // date-preset 상세 설정 // ======================================== diff --git a/frontend/lib/registry/pop-components/pop-search/types.ts b/frontend/lib/registry/pop-components/pop-search/types.ts index 1673d027..220d5ff9 100644 --- a/frontend/lib/registry/pop-components/pop-search/types.ts +++ b/frontend/lib/registry/pop-components/pop-search/types.ts @@ -22,6 +22,12 @@ export function normalizeInputType(t: string): 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"; @@ -84,6 +90,10 @@ export interface PopSearchConfig { options?: SelectOption[]; optionsDataSource?: SelectDataSource; + // date 전용 + dateSelectionMode?: DateSelectionMode; + calendarDisplay?: CalendarDisplayMode; + // date-preset 전용 datePresets?: DatePresetOption[]; @@ -94,9 +104,6 @@ export interface PopSearchConfig { labelText?: string; labelVisible?: boolean; - // 스타일 - labelPosition?: "top" | "left"; - // 연결된 리스트에 필터를 보낼 때의 매칭 방식 filterMode?: SearchFilterMode; @@ -111,7 +118,6 @@ export const DEFAULT_SEARCH_CONFIG: PopSearchConfig = { placeholder: "검색어 입력", debounceMs: 500, triggerOnEnter: true, - labelPosition: "top", labelText: "", labelVisible: true, }; diff --git a/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx index 567f6d1d..a9b77c27 100644 --- a/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx @@ -192,10 +192,9 @@ export function PopStringListComponent({ row: RowData, filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } } ): boolean => { - const searchValue = String(filter.value).toLowerCase(); - if (!searchValue) return true; - const fc = filter.filterConfig; + const mode = fc?.filterMode || "contains"; + const columns: string[] = fc?.targetColumns?.length ? fc.targetColumns @@ -207,17 +206,46 @@ export function PopStringListComponent({ 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 target = isDateValue && mode === "equals" ? cellValue.slice(0, 10) : cellValue; switch (mode) { case "equals": - return cellValue === searchValue; + return target === searchValue; case "starts_with": - return cellValue.startsWith(searchValue); + return target.startsWith(searchValue); case "contains": default: - return cellValue.includes(searchValue); + return target.includes(searchValue); } };