From 17d4cc297c604ec999568033ceb53aa25815c498 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 26 Feb 2026 17:32:20 +0900 Subject: [PATCH] feat: Introduce new date picker components for enhanced date selection - Added `FormDatePicker` and `InlineCellDatePicker` components to provide flexible date selection options. - Implemented a modernized date picker interface with calendar navigation, year selection, and time input capabilities. - Enhanced `DateWidget` to support both date and datetime formats, improving user experience in date handling. - Updated `CategoryColumnList` to group columns dynamically and manage expanded states for better organization. - Improved `AlertDialog` z-index for better visibility in modal interactions. - Refactored `ScreenModal` to ensure consistent modal behavior across the application. --- frontend/components/common/ScreenModal.tsx | 2 +- .../screen/filters/FormDatePicker.tsx | 344 ++++++++++++ .../screen/filters/InlineCellDatePicker.tsx | 279 ++++++++++ .../screen/filters/ModernDatePicker.tsx | 199 +++++-- .../screen/table-options/GroupingPanel.tsx | 2 +- .../table-options/TableSettingsModal.tsx | 2 +- .../screen/widgets/types/DateWidget.tsx | 407 +++++++++++--- .../table-category/CategoryColumnList.tsx | 166 +++++- frontend/components/ui/alert-dialog.tsx | 4 +- frontend/components/v2/V2Date.tsx | 496 +++++++++++++----- .../button-primary/ButtonPrimaryComponent.tsx | 4 +- .../image-display/ImageDisplayComponent.tsx | 73 ++- .../image-display/ImageDisplayConfigPanel.tsx | 151 +++++- .../components/image-display/config.ts | 43 +- .../components/image-display/index.ts | 8 +- .../components/image-display/types.ts | 18 +- .../UniversalFormModalComponent.tsx | 14 +- .../ButtonPrimaryComponent.tsx | 4 +- .../v2-file-upload/FileManagerModal.tsx | 57 +- .../v2-file-upload/FileUploadComponent.tsx | 111 ++-- .../v2-table-list/TableListComponent.tsx | 43 +- .../TableSearchWidget.tsx | 35 +- 22 files changed, 2001 insertions(+), 461 deletions(-) create mode 100644 frontend/components/screen/filters/FormDatePicker.tsx create mode 100644 frontend/components/screen/filters/InlineCellDatePicker.tsx diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index a79f26e3..854b1159 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -1288,7 +1288,7 @@ export const ScreenModal: React.FC = ({ className }) => { {/* 모달 닫기 확인 다이얼로그 */} - + 화면을 닫으시겠습니까? diff --git a/frontend/components/screen/filters/FormDatePicker.tsx b/frontend/components/screen/filters/FormDatePicker.tsx new file mode 100644 index 00000000..5ab5a1ff --- /dev/null +++ b/frontend/components/screen/filters/FormDatePicker.tsx @@ -0,0 +1,344 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { CalendarIcon, ChevronLeft, ChevronRight, X } from "lucide-react"; +import { + format, + addMonths, + subMonths, + startOfMonth, + endOfMonth, + eachDayOfInterval, + isSameMonth, + isSameDay, + isToday, +} from "date-fns"; +import { ko } from "date-fns/locale"; +import { cn } from "@/lib/utils"; + +interface FormDatePickerProps { + id?: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + disabled?: boolean; + readOnly?: boolean; + includeTime?: boolean; +} + +export const FormDatePicker: React.FC = ({ + id, + value, + onChange, + placeholder, + disabled = false, + readOnly = false, + includeTime = false, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const [timeValue, setTimeValue] = useState("00:00"); + const [isTyping, setIsTyping] = useState(false); + const [typingValue, setTypingValue] = useState(""); + + const parseDate = (val: string): Date | undefined => { + if (!val) return undefined; + try { + const date = new Date(val); + if (isNaN(date.getTime())) return undefined; + return date; + } catch { + return undefined; + } + }; + + const selectedDate = parseDate(value); + + useEffect(() => { + if (isOpen) { + setViewMode("calendar"); + if (selectedDate) { + setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); + setYearRangeStart(Math.floor(selectedDate.getFullYear() / 12) * 12); + if (includeTime) { + const hours = String(selectedDate.getHours()).padStart(2, "0"); + const minutes = String(selectedDate.getMinutes()).padStart(2, "0"); + setTimeValue(`${hours}:${minutes}`); + } + } else { + setCurrentMonth(new Date()); + setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12); + setTimeValue("00:00"); + } + } else { + setIsTyping(false); + setTypingValue(""); + } + }, [isOpen]); + + const formatDisplayValue = (): string => { + if (!selectedDate) return ""; + if (includeTime) return format(selectedDate, "yyyy-MM-dd HH:mm", { locale: ko }); + return format(selectedDate, "yyyy-MM-dd", { locale: ko }); + }; + + const buildDateStr = (date: Date, time?: string) => { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + if (includeTime) return `${y}-${m}-${d}T${time || timeValue}`; + return `${y}-${m}-${d}`; + }; + + const handleDateClick = (date: Date) => { + onChange(buildDateStr(date)); + if (!includeTime) setIsOpen(false); + }; + + const handleTimeChange = (newTime: string) => { + setTimeValue(newTime); + if (selectedDate) onChange(buildDateStr(selectedDate, newTime)); + }; + + const handleSetToday = () => { + const today = new Date(); + if (includeTime) { + const t = `${String(today.getHours()).padStart(2, "0")}:${String(today.getMinutes()).padStart(2, "0")}`; + onChange(buildDateStr(today, t)); + } else { + onChange(buildDateStr(today)); + } + setIsOpen(false); + }; + + const handleClear = () => { + onChange(""); + setIsTyping(false); + setIsOpen(false); + }; + + const handleTriggerInput = (raw: string) => { + setIsTyping(true); + setTypingValue(raw); + if (!isOpen) setIsOpen(true); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const y = parseInt(digitsOnly.slice(0, 4), 10); + const m = parseInt(digitsOnly.slice(4, 6), 10) - 1; + const d = parseInt(digitsOnly.slice(6, 8), 10); + const date = new Date(y, m, d); + if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) { + onChange(buildDateStr(date)); + setCurrentMonth(new Date(y, m, 1)); + if (!includeTime) setTimeout(() => { setIsTyping(false); setIsOpen(false); }, 400); + else setIsTyping(false); + } + } + }; + + const monthStart = startOfMonth(currentMonth); + const monthEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: monthStart, end: monthEnd }); + const dayOfWeek = monthStart.getDay(); + const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const allDays = [...Array(paddingDays).fill(null), ...days]; + + return ( + { if (!open) { setIsOpen(false); setIsTyping(false); } }}> + +
{ if (!disabled && !readOnly) setIsOpen(true); }} + > + + handleTriggerInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onFocus={() => { if (!disabled && !readOnly && !isOpen) setIsOpen(true); }} + onBlur={() => { if (!isOpen) setIsTyping(false); }} + className="h-full w-full bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed" + /> + {selectedDate && !disabled && !readOnly && !isTyping && ( + { + e.stopPropagation(); + handleClear(); + }} + /> + )} +
+
+ e.preventDefault()}> +
+
+ + +
+ + {viewMode === "year" ? ( + <> +
+ +
{yearRangeStart} - {yearRangeStart + 11}
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+ +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {allDays.map((date, index) => { + if (!date) return
; + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = selectedDate ? isSameDay(date, selectedDate) : false; + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} + + {includeTime && viewMode === "calendar" && ( +
+ 시간: + handleTimeChange(e.target.value)} + className="border-input h-8 rounded-md border px-2 text-xs" + /> +
+ )} +
+ + + ); +}; diff --git a/frontend/components/screen/filters/InlineCellDatePicker.tsx b/frontend/components/screen/filters/InlineCellDatePicker.tsx new file mode 100644 index 00000000..f47546b4 --- /dev/null +++ b/frontend/components/screen/filters/InlineCellDatePicker.tsx @@ -0,0 +1,279 @@ +"use client"; + +import React, { useState, useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { + format, + addMonths, + subMonths, + startOfMonth, + endOfMonth, + eachDayOfInterval, + isSameMonth, + isSameDay, + isToday, +} from "date-fns"; +import { ko } from "date-fns/locale"; +import { cn } from "@/lib/utils"; + +interface InlineCellDatePickerProps { + value: string; + onChange: (value: string) => void; + onSave: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + inputRef?: React.RefObject; +} + +export const InlineCellDatePicker: React.FC = ({ + value, + onChange, + onSave, + onKeyDown, + inputRef, +}) => { + const [isOpen, setIsOpen] = useState(true); + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const localInputRef = useRef(null); + const actualInputRef = inputRef || localInputRef; + + const parseDate = (val: string): Date | undefined => { + if (!val) return undefined; + try { + const date = new Date(val); + if (isNaN(date.getTime())) return undefined; + return date; + } catch { + return undefined; + } + }; + + const selectedDate = parseDate(value); + + useEffect(() => { + if (selectedDate) { + setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); + } + }, []); + + const handleDateClick = (date: Date) => { + const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + onChange(dateStr); + setIsOpen(false); + setTimeout(() => onSave(), 50); + }; + + const handleSetToday = () => { + const today = new Date(); + const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + onChange(dateStr); + setIsOpen(false); + setTimeout(() => onSave(), 50); + }; + + const handleClear = () => { + onChange(""); + setIsOpen(false); + setTimeout(() => onSave(), 50); + }; + + const handleInputChange = (raw: string) => { + onChange(raw); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const y = parseInt(digitsOnly.slice(0, 4), 10); + const m = parseInt(digitsOnly.slice(4, 6), 10) - 1; + const d = parseInt(digitsOnly.slice(6, 8), 10); + const date = new Date(y, m, d); + if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) { + const dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; + onChange(dateStr); + setIsOpen(false); + setTimeout(() => onSave(), 50); + } + } + }; + + const handlePopoverClose = (open: boolean) => { + if (!open) { + setIsOpen(false); + onSave(); + } + }; + + const monthStart = startOfMonth(currentMonth); + const monthEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: monthStart, end: monthEnd }); + + const startDate = new Date(monthStart); + const dayOfWeek = startDate.getDay(); + const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const allDays = [...Array(paddingDays).fill(null), ...days]; + + return ( + + + handleInputChange(e.target.value)} + onKeyDown={onKeyDown} + onClick={() => setIsOpen(true)} + placeholder="YYYYMMDD" + className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm" + /> + + e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}> +
+
+ + +
+ + {viewMode === "year" ? ( + <> +
+ +
+ {yearRangeStart} - {yearRangeStart + 11} +
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+ +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {allDays.map((date, index) => { + if (!date) return
; + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = selectedDate ? isSameDay(date, selectedDate) : false; + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} +
+ + + ); +}; diff --git a/frontend/components/screen/filters/ModernDatePicker.tsx b/frontend/components/screen/filters/ModernDatePicker.tsx index 54fdcfed..79f16a41 100644 --- a/frontend/components/screen/filters/ModernDatePicker.tsx +++ b/frontend/components/screen/filters/ModernDatePicker.tsx @@ -34,6 +34,8 @@ export const ModernDatePicker: React.FC = ({ label, value const [isOpen, setIsOpen] = useState(false); const [currentMonth, setCurrentMonth] = useState(new Date()); const [selectingType, setSelectingType] = useState<"from" | "to">("from"); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); // 로컬 임시 상태 (확인 버튼 누르기 전까지 임시 저장) const [tempValue, setTempValue] = useState(value || {}); @@ -43,6 +45,7 @@ export const ModernDatePicker: React.FC = ({ label, value if (isOpen) { setTempValue(value || {}); setSelectingType("from"); + setViewMode("calendar"); } }, [isOpen, value]); @@ -234,60 +237,150 @@ export const ModernDatePicker: React.FC = ({ label, value
- {/* 월 네비게이션 */} -
- -
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
- -
- - {/* 요일 헤더 */} -
- {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( -
- {day} -
- ))} -
- - {/* 날짜 그리드 */} -
- {allDays.map((date, index) => { - if (!date) { - return
; - } - - const isCurrentMonth = isSameMonth(date, currentMonth); - const isSelected = isRangeStart(date) || isRangeEnd(date); - const isInRangeDate = isInRange(date); - const isTodayDate = isToday(date); - - return ( - - ); - })} -
+
+ {yearRangeStart} - {yearRangeStart + 11} +
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> + {/* 월 선택 뷰 */} +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> + {/* 월 네비게이션 */} +
+ + + +
+ + {/* 요일 헤더 */} +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ + {/* 날짜 그리드 */} +
+ {allDays.map((date, index) => { + if (!date) { + return
; + } + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = isRangeStart(date) || isRangeEnd(date); + const isInRangeDate = isInRange(date); + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} {/* 선택된 범위 표시 */} {(tempValue.from || tempValue.to) && ( diff --git a/frontend/components/screen/table-options/GroupingPanel.tsx b/frontend/components/screen/table-options/GroupingPanel.tsx index 0495991d..867448d0 100644 --- a/frontend/components/screen/table-options/GroupingPanel.tsx +++ b/frontend/components/screen/table-options/GroupingPanel.tsx @@ -99,7 +99,7 @@ export const GroupingPanel: React.FC = ({ 전체 해제
-
+
{selectedColumns.map((colName, index) => { const col = table?.columns.find( (c) => c.columnName === colName diff --git a/frontend/components/screen/table-options/TableSettingsModal.tsx b/frontend/components/screen/table-options/TableSettingsModal.tsx index ef07e017..4f9325cb 100644 --- a/frontend/components/screen/table-options/TableSettingsModal.tsx +++ b/frontend/components/screen/table-options/TableSettingsModal.tsx @@ -557,7 +557,7 @@ export const TableSettingsModal: React.FC = ({ isOpen, onClose, onFilters 전체 해제
-
+
{selectedGroupColumns.map((colName, index) => { const col = table?.columns.find((c) => c.columnName === colName); if (!col) return null; diff --git a/frontend/components/screen/widgets/types/DateWidget.tsx b/frontend/components/screen/widgets/types/DateWidget.tsx index edb78df9..3b0f47e2 100644 --- a/frontend/components/screen/widgets/types/DateWidget.tsx +++ b/frontend/components/screen/widgets/types/DateWidget.tsx @@ -1,7 +1,22 @@ "use client"; -import React from "react"; -import { Input } from "@/components/ui/input"; +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { CalendarIcon, ChevronLeft, ChevronRight, X } from "lucide-react"; +import { + format, + addMonths, + subMonths, + startOfMonth, + endOfMonth, + eachDayOfInterval, + isSameMonth, + isSameDay, + isToday, +} from "date-fns"; +import { ko } from "date-fns/locale"; +import { cn } from "@/lib/utils"; import { WebTypeComponentProps } from "@/lib/registry/types"; import { WidgetComponent, DateTypeConfig } from "@/types/screen"; @@ -10,99 +25,341 @@ export const DateWidget: React.FC = ({ component, value, const { placeholder, required, style } = widget; const config = widget.webTypeConfig as DateTypeConfig | undefined; - // 사용자가 테두리를 설정했는지 확인 const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border); const borderClass = hasCustomBorder ? "!border-0" : ""; - // 날짜 포맷팅 함수 - const formatDateValue = (val: string) => { - if (!val) return ""; + const isDatetime = widget.widgetType === "datetime"; + const [isOpen, setIsOpen] = useState(false); + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const [timeValue, setTimeValue] = useState("00:00"); + const [isTyping, setIsTyping] = useState(false); + const [typingValue, setTypingValue] = useState(""); + + const parseDate = (val: string | undefined): Date | undefined => { + if (!val) return undefined; try { const date = new Date(val); - if (isNaN(date.getTime())) return val; - - if (widget.widgetType === "datetime") { - return date.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm - } else { - return date.toISOString().slice(0, 10); // YYYY-MM-DD - } + if (isNaN(date.getTime())) return undefined; + return date; } catch { - return val; + return undefined; } }; - // 날짜 유효성 검증 - const validateDate = (dateStr: string): boolean => { - if (!dateStr) return true; - - const date = new Date(dateStr); - if (isNaN(date.getTime())) return false; - - // 최소/최대 날짜 검증 - if (config?.minDate) { - const minDate = new Date(config.minDate); - if (date < minDate) return false; - } - - if (config?.maxDate) { - const maxDate = new Date(config.maxDate); - if (date > maxDate) return false; - } - - return true; - }; - - // 입력값 처리 - const handleChange = (e: React.ChangeEvent) => { - const inputValue = e.target.value; - - if (validateDate(inputValue)) { - onChange?.(inputValue); - } - }; - - // 웹타입에 따른 input type 결정 - const getInputType = () => { - switch (widget.widgetType) { - case "datetime": - return "datetime-local"; - case "date": - default: - return "date"; - } - }; - - // 기본값 설정 (현재 날짜/시간) - const getDefaultValue = () => { + const getDefaultValue = (): string => { if (config?.defaultValue === "current") { const now = new Date(); - if (widget.widgetType === "datetime") { - return now.toISOString().slice(0, 16); - } else { - return now.toISOString().slice(0, 10); - } + if (isDatetime) return now.toISOString().slice(0, 16); + return now.toISOString().slice(0, 10); } return ""; }; const finalValue = value || getDefaultValue(); + const selectedDate = parseDate(finalValue); + + useEffect(() => { + if (isOpen) { + setViewMode("calendar"); + if (selectedDate) { + setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); + if (isDatetime) { + const hours = String(selectedDate.getHours()).padStart(2, "0"); + const minutes = String(selectedDate.getMinutes()).padStart(2, "0"); + setTimeValue(`${hours}:${minutes}`); + } + } else { + setCurrentMonth(new Date()); + setTimeValue("00:00"); + } + } else { + setIsTyping(false); + setTypingValue(""); + } + }, [isOpen]); + + const formatDisplayValue = (): string => { + if (!selectedDate) return ""; + if (isDatetime) return format(selectedDate, "yyyy-MM-dd HH:mm", { locale: ko }); + return format(selectedDate, "yyyy-MM-dd", { locale: ko }); + }; + + const handleDateClick = (date: Date) => { + let dateStr: string; + if (isDatetime) { + const [hours, minutes] = timeValue.split(":").map(Number); + const dt = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hours || 0, minutes || 0); + dateStr = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")}T${timeValue}`; + } else { + dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + } + onChange?.(dateStr); + if (!isDatetime) { + setIsOpen(false); + } + }; + + const handleTimeChange = (newTime: string) => { + setTimeValue(newTime); + if (selectedDate) { + const dateStr = `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, "0")}-${String(selectedDate.getDate()).padStart(2, "0")}T${newTime}`; + onChange?.(dateStr); + } + }; + + const handleClear = () => { + onChange?.(""); + setIsTyping(false); + setIsOpen(false); + }; + + const handleTriggerInput = (raw: string) => { + setIsTyping(true); + setTypingValue(raw); + if (!isOpen) setIsOpen(true); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const y = parseInt(digitsOnly.slice(0, 4), 10); + const m = parseInt(digitsOnly.slice(4, 6), 10) - 1; + const d = parseInt(digitsOnly.slice(6, 8), 10); + const date = new Date(y, m, d); + if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) { + let dateStr: string; + if (isDatetime) { + dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}T${timeValue}`; + } else { + dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; + } + onChange?.(dateStr); + setCurrentMonth(new Date(y, m, 1)); + if (!isDatetime) setTimeout(() => { setIsTyping(false); setIsOpen(false); }, 400); + else setIsTyping(false); + } + } + }; + + const handleSetToday = () => { + const today = new Date(); + if (isDatetime) { + const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}T${String(today.getHours()).padStart(2, "0")}:${String(today.getMinutes()).padStart(2, "0")}`; + onChange?.(dateStr); + } else { + const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + onChange?.(dateStr); + } + setIsOpen(false); + }; + + const monthStart = startOfMonth(currentMonth); + const monthEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: monthStart, end: monthEnd }); + + const startDate = new Date(monthStart); + const dayOfWeek = startDate.getDay(); + const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const allDays = [...Array(paddingDays).fill(null), ...days]; return ( - + { if (!v) { setIsOpen(false); setIsTyping(false); } }}> + +
{ if (!readonly) setIsOpen(true); }} + > + + handleTriggerInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onFocus={() => { if (!readonly && !isOpen) setIsOpen(true); }} + onBlur={() => { if (!isOpen) setIsTyping(false); }} + className="h-full w-full truncate bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed" + /> + {selectedDate && !readonly && !isTyping && ( + { + e.stopPropagation(); + handleClear(); + }} + /> + )} +
+
+ e.preventDefault()}> +
+
+ + +
+ + {viewMode === "year" ? ( + <> +
+ +
+ {yearRangeStart} - {yearRangeStart + 11} +
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+ +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {allDays.map((date, index) => { + if (!date) return
; + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = selectedDate ? isSameDay(date, selectedDate) : false; + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} + + {/* datetime 타입: 시간 입력 */} + {isDatetime && viewMode === "calendar" && ( +
+ 시간: + handleTimeChange(e.target.value)} + className="border-input h-8 rounded-md border px-2 text-xs" + /> +
+ )} + +
+ + ); }; DateWidget.displayName = "DateWidget"; - - diff --git a/frontend/components/table-category/CategoryColumnList.tsx b/frontend/components/table-category/CategoryColumnList.tsx index d6ed8c62..872e7d57 100644 --- a/frontend/components/table-category/CategoryColumnList.tsx +++ b/frontend/components/table-category/CategoryColumnList.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect, useMemo } from "react"; import { apiClient } from "@/lib/api/client"; import { getCategoryValues } from "@/lib/api/tableCategoryValue"; -import { FolderTree, Loader2, Search, X } from "lucide-react"; +import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react"; import { Input } from "@/components/ui/input"; interface CategoryColumn { @@ -30,6 +30,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, const [columns, setColumns] = useState([]); const [isLoading, setIsLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); // 검색어로 필터링된 컬럼 목록 const filteredColumns = useMemo(() => { @@ -49,6 +50,44 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, }); }, [columns, searchQuery]); + // 테이블별로 그룹화된 컬럼 목록 + const groupedColumns = useMemo(() => { + const groups: { tableName: string; tableLabel: string; columns: CategoryColumn[] }[] = []; + const groupMap = new Map(); + + for (const col of filteredColumns) { + const key = col.tableName; + if (!groupMap.has(key)) { + groupMap.set(key, []); + } + groupMap.get(key)!.push(col); + } + + for (const [tblName, cols] of groupMap) { + groups.push({ + tableName: tblName, + tableLabel: cols[0]?.tableLabel || tblName, + columns: cols, + }); + } + + return groups; + }, [filteredColumns]); + + // 선택된 컬럼이 있는 그룹을 자동 펼침 + useEffect(() => { + if (!selectedColumn) return; + const tableName = selectedColumn.split(".")[0]; + if (tableName) { + setExpandedGroups((prev) => { + if (prev.has(tableName)) return prev; + const next = new Set(prev); + next.add(tableName); + return next; + }); + } + }, [selectedColumn]); + useEffect(() => { // 메뉴 종속 없이 항상 회사 기준으로 카테고리 컬럼 조회 loadCategoryColumnsByMenu(); @@ -279,35 +318,114 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, )}
-
+
{filteredColumns.length === 0 && searchQuery ? (
'{searchQuery}'에 대한 검색 결과가 없습니다
) : null} - {filteredColumns.map((column) => { - const uniqueKey = `${column.tableName}.${column.columnName}`; - const isSelected = selectedColumn === uniqueKey; // 테이블명.컬럼명으로 비교 - return ( -
onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)} - className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${ - isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50" - }`} - > -
- -
-

{column.columnLabel || column.columnName}

-

{column.tableLabel || column.tableName}

-
- - {column.valueCount !== undefined ? `${column.valueCount}개` : "..."} - + {groupedColumns.map((group) => { + const isExpanded = expandedGroups.has(group.tableName); + const totalValues = group.columns.reduce((sum, c) => sum + (c.valueCount ?? 0), 0); + const hasSelectedInGroup = group.columns.some( + (c) => selectedColumn === `${c.tableName}.${c.columnName}`, + ); + + // 그룹이 1개뿐이면 드롭다운 없이 바로 표시 + if (groupedColumns.length <= 1) { + return ( +
+ {group.columns.map((column) => { + const uniqueKey = `${column.tableName}.${column.columnName}`; + const isSelected = selectedColumn === uniqueKey; + return ( +
onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)} + className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${ + isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50" + }`} + > +
+ +
+

{column.columnLabel || column.columnName}

+

{column.tableLabel || column.tableName}

+
+ + {column.valueCount !== undefined ? `${column.valueCount}개` : "..."} + +
+
+ ); + })}
+ ); + } + + return ( +
+ {/* 드롭다운 헤더 */} + + + {/* 펼쳐진 컬럼 목록 */} + {isExpanded && ( +
+ {group.columns.map((column) => { + const uniqueKey = `${column.tableName}.${column.columnName}`; + const isSelected = selectedColumn === uniqueKey; + return ( +
onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)} + className={`cursor-pointer rounded-md px-3 py-1.5 transition-all ${ + isSelected ? "bg-primary/10 font-semibold text-primary" : "hover:bg-muted/50" + }`} + > +
+ + {column.columnLabel || column.columnName} + + {column.valueCount !== undefined ? `${column.valueCount}개` : "..."} + +
+
+ ); + })} +
+ )}
); })} diff --git a/frontend/components/ui/alert-dialog.tsx b/frontend/components/ui/alert-dialog.tsx index 3c7a9239..2da0647f 100644 --- a/frontend/components/ui/alert-dialog.tsx +++ b/frontend/components/ui/alert-dialog.tsx @@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( 2100) return null; + return date; +} + /** * 단일 날짜 선택 컴포넌트 */ const SingleDatePicker = forwardRef< - HTMLButtonElement, + HTMLDivElement, { value?: string; onChange?: (value: string) => void; @@ -83,80 +95,227 @@ const SingleDatePicker = forwardRef< ref, ) => { const [open, setOpen] = useState(false); + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const [isTyping, setIsTyping] = useState(false); + const [typingValue, setTypingValue] = useState(""); + const inputRef = React.useRef(null); const date = useMemo(() => parseDate(value, dateFormat), [value, dateFormat]); - const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]); - const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]); - // 표시할 날짜 텍스트 계산 (ISO 형식이면 포맷팅, 아니면 그대로) const displayText = useMemo(() => { if (!value) return ""; - // Date 객체로 변환 후 포맷팅 - if (date && isValid(date)) { - return formatDate(date, dateFormat); - } + if (date && isValid(date)) return formatDate(date, dateFormat); return value; }, [value, date, dateFormat]); - const handleSelect = useCallback( - (selectedDate: Date | undefined) => { - if (selectedDate) { - onChange?.(formatDate(selectedDate, dateFormat)); - setOpen(false); + useEffect(() => { + if (open) { + setViewMode("calendar"); + if (date && isValid(date)) { + setCurrentMonth(new Date(date.getFullYear(), date.getMonth(), 1)); + setYearRangeStart(Math.floor(date.getFullYear() / 12) * 12); + } else { + setCurrentMonth(new Date()); + setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12); } - }, - [dateFormat, onChange], - ); + } else { + setIsTyping(false); + setTypingValue(""); + } + }, [open]); + + const handleDateClick = useCallback((clickedDate: Date) => { + onChange?.(formatDate(clickedDate, dateFormat)); + setIsTyping(false); + setOpen(false); + }, [dateFormat, onChange]); const handleToday = useCallback(() => { onChange?.(formatDate(new Date(), dateFormat)); + setIsTyping(false); setOpen(false); }, [dateFormat, onChange]); const handleClear = useCallback(() => { onChange?.(""); + setIsTyping(false); setOpen(false); }, [onChange]); + const handleTriggerInput = useCallback((raw: string) => { + setIsTyping(true); + setTypingValue(raw); + if (!open) setOpen(true); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const parsed = parseManualDateInput(digitsOnly); + if (parsed) { + onChange?.(formatDate(parsed, dateFormat)); + setCurrentMonth(new Date(parsed.getFullYear(), parsed.getMonth(), 1)); + setTimeout(() => { setIsTyping(false); setOpen(false); }, 400); + } + } + }, [dateFormat, onChange, open]); + + const mStart = startOfMonth(currentMonth); + const mEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: mStart, end: mEnd }); + const dow = mStart.getDay(); + const padding = dow === 0 ? 6 : dow - 1; + const allDays = [...Array(padding).fill(null), ...days]; + return ( - + { if (!v) { setOpen(false); setIsTyping(false); } }}> - + + handleTriggerInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onFocus={() => { if (!disabled && !readonly && !open) setOpen(true); }} + onBlur={() => { if (!open) setIsTyping(false); }} + className={cn( + "h-full w-full bg-transparent text-sm outline-none", + "placeholder:text-muted-foreground disabled:cursor-not-allowed", + !displayText && !isTyping && "text-muted-foreground", + )} + /> +
- - { - if (minDateObj && date < minDateObj) return true; - if (maxDateObj && date > maxDateObj) return true; - return false; - }} - /> -
- {showToday && ( - + )} + +
+ + {viewMode === "year" ? ( + <> +
+ +
{yearRangeStart} - {yearRangeStart + 11}
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+
+ {["월", "화", "수", "목", "금", "토", "일"].map((d) => ( +
{d}
+ ))} +
+
+ {allDays.map((d, idx) => { + if (!d) return
; + const isCur = isSameMonth(d, currentMonth); + const isSel = date ? isSameDay(d, date) : false; + const isT = isTodayFn(d); + return ( + + ); + })} +
+ )} -
@@ -168,6 +327,149 @@ SingleDatePicker.displayName = "SingleDatePicker"; /** * 날짜 범위 선택 컴포넌트 */ +/** + * 범위 날짜 팝오버 내부 캘린더 (drill-down 지원) + */ +const RangeCalendarPopover: React.FC<{ + open: boolean; + onOpenChange: (open: boolean) => void; + selectedDate?: Date; + onSelect: (date: Date) => void; + label: string; + disabled?: boolean; + readonly?: boolean; + displayValue?: string; +}> = ({ open, onOpenChange, selectedDate, onSelect, label, disabled, readonly, displayValue }) => { + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const [isTyping, setIsTyping] = useState(false); + const [typingValue, setTypingValue] = useState(""); + + useEffect(() => { + if (open) { + setViewMode("calendar"); + if (selectedDate && isValid(selectedDate)) { + setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); + setYearRangeStart(Math.floor(selectedDate.getFullYear() / 12) * 12); + } else { + setCurrentMonth(new Date()); + setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12); + } + } else { + setIsTyping(false); + setTypingValue(""); + } + }, [open]); + + const handleTriggerInput = (raw: string) => { + setIsTyping(true); + setTypingValue(raw); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const parsed = parseManualDateInput(digitsOnly); + if (parsed) { + setIsTyping(false); + onSelect(parsed); + } + } + }; + + const mStart = startOfMonth(currentMonth); + const mEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: mStart, end: mEnd }); + const dow = mStart.getDay(); + const padding = dow === 0 ? 6 : dow - 1; + const allDays = [...Array(padding).fill(null), ...days]; + + return ( + { if (!v) { setIsTyping(false); } onOpenChange(v); }}> + +
{ if (!disabled && !readonly) onOpenChange(true); }} + > + + handleTriggerInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onFocus={() => { if (!disabled && !readonly && !open) onOpenChange(true); }} + onBlur={() => { if (!open) setIsTyping(false); }} + className="h-full w-full bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed" + /> +
+
+ e.preventDefault()}> +
+ {viewMode === "year" ? ( + <> +
+ +
{yearRangeStart} - {yearRangeStart + 11}
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+
+ {["월", "화", "수", "목", "금", "토", "일"].map((d) => ( +
{d}
+ ))} +
+
+ {allDays.map((d, idx) => { + if (!d) return
; + const isCur = isSameMonth(d, currentMonth); + const isSel = selectedDate ? isSameDay(d, selectedDate) : false; + const isT = isTodayFn(d); + return ( + + ); + })} +
+ + )} +
+ + + ); +}; + const RangeDatePicker = forwardRef< HTMLDivElement, { @@ -186,102 +488,38 @@ const RangeDatePicker = forwardRef< const startDate = useMemo(() => parseDate(value[0], dateFormat), [value, dateFormat]); const endDate = useMemo(() => parseDate(value[1], dateFormat), [value, dateFormat]); - const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]); - const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]); const handleStartSelect = useCallback( - (date: Date | undefined) => { - if (date) { - const newStart = formatDate(date, dateFormat); - // 시작일이 종료일보다 크면 종료일도 같이 변경 - if (endDate && date > endDate) { - onChange?.([newStart, newStart]); - } else { - onChange?.([newStart, value[1]]); - } - setOpenStart(false); + (date: Date) => { + const newStart = formatDate(date, dateFormat); + if (endDate && date > endDate) { + onChange?.([newStart, newStart]); + } else { + onChange?.([newStart, value[1]]); } + setOpenStart(false); }, [value, dateFormat, endDate, onChange], ); const handleEndSelect = useCallback( - (date: Date | undefined) => { - if (date) { - const newEnd = formatDate(date, dateFormat); - // 종료일이 시작일보다 작으면 시작일도 같이 변경 - if (startDate && date < startDate) { - onChange?.([newEnd, newEnd]); - } else { - onChange?.([value[0], newEnd]); - } - setOpenEnd(false); + (date: Date) => { + const newEnd = formatDate(date, dateFormat); + if (startDate && date < startDate) { + onChange?.([newEnd, newEnd]); + } else { + onChange?.([value[0], newEnd]); } + setOpenEnd(false); }, [value, dateFormat, startDate, onChange], ); return (
- {/* 시작 날짜 */} - - - - - - { - if (minDateObj && date < minDateObj) return true; - if (maxDateObj && date > maxDateObj) return true; - return false; - }} - /> - - - + ~ - - {/* 종료 날짜 */} - - - - - - { - if (minDateObj && date < minDateObj) return true; - if (maxDateObj && date > maxDateObj) return true; - // 시작일보다 이전 날짜는 선택 불가 - if (startDate && date < startDate) return true; - return false; - }} - /> - - +
); }); diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 173a67ad..f753a240 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -1328,9 +1328,9 @@ export const ButtonPrimaryComponent: React.FC = ({ )}
- {/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */} + {/* 확인 다이얼로그 */} - + {getConfirmTitle()} {getConfirmMessage()} diff --git a/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx b/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx index 13a7ac4f..2f35c799 100644 --- a/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx +++ b/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx @@ -24,29 +24,33 @@ export const ImageDisplayComponent: React.FC = ({ style, ...props }) => { - // 컴포넌트 설정 const componentConfig = { ...config, ...component.config, } as ImageDisplayConfig; - // 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외) + const objectFit = componentConfig.objectFit || "contain"; + const altText = componentConfig.altText || "이미지"; + const borderRadius = componentConfig.borderRadius ?? 8; + const showBorder = componentConfig.showBorder ?? true; + const backgroundColor = componentConfig.backgroundColor || "#f9fafb"; + const placeholder = componentConfig.placeholder || "이미지 없음"; + + const imageSrc = component.value || componentConfig.imageUrl || ""; + const componentStyle: React.CSSProperties = { width: "100%", height: "100%", ...component.style, ...style, - // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) width: "100%", }; - // 디자인 모드 스타일 if (isDesignMode) { componentStyle.border = "1px dashed #cbd5e1"; componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1"; } - // 이벤트 핸들러 const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); onClick?.(); @@ -88,7 +92,9 @@ export const ImageDisplayComponent: React.FC = ({ }} > {component.label} - {component.required && *} + {(component.required || componentConfig.required) && ( + * + )} )} @@ -96,43 +102,53 @@ export const ImageDisplayComponent: React.FC = ({ style={{ width: "100%", height: "100%", - border: "1px solid #d1d5db", - borderRadius: "8px", + border: showBorder ? "1px solid #d1d5db" : "none", + borderRadius: `${borderRadius}px`, overflow: "hidden", display: "flex", alignItems: "center", justifyContent: "center", - backgroundColor: "#f9fafb", + backgroundColor, transition: "all 0.2s ease-in-out", - boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)", + boxShadow: showBorder ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : "none", + opacity: componentConfig.disabled ? 0.5 : 1, + cursor: componentConfig.disabled ? "not-allowed" : "default", }} onMouseEnter={(e) => { - e.currentTarget.style.borderColor = "#f97316"; - e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)"; + if (!componentConfig.disabled) { + if (showBorder) { + e.currentTarget.style.borderColor = "#f97316"; + } + e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)"; + } }} onMouseLeave={(e) => { - e.currentTarget.style.borderColor = "#d1d5db"; - e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)"; + if (showBorder) { + e.currentTarget.style.borderColor = "#d1d5db"; + } + e.currentTarget.style.boxShadow = showBorder + ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" + : "none"; }} onClick={handleClick} onDragStart={onDragStart} onDragEnd={onDragEnd} > - {component.value || componentConfig.imageUrl ? ( + {imageSrc ? ( {componentConfig.altText { (e.target as HTMLImageElement).style.display = "none"; if (e.target?.parentElement) { e.target.parentElement.innerHTML = `
-
🖼️
+
이미지 로드 실패
`; @@ -150,8 +166,22 @@ export const ImageDisplayComponent: React.FC = ({ fontSize: "14px", }} > -
🖼️
-
이미지 없음
+ + + + + +
{placeholder}
)}
@@ -161,7 +191,6 @@ export const ImageDisplayComponent: React.FC = ({ /** * ImageDisplay 래퍼 컴포넌트 - * 추가적인 로직이나 상태 관리가 필요한 경우 사용 */ export const ImageDisplayWrapper: React.FC = (props) => { return ; diff --git a/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx b/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx index 6c73e1d9..7f36f51b 100644 --- a/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx +++ b/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx @@ -9,63 +9,166 @@ import { ImageDisplayConfig } from "./types"; export interface ImageDisplayConfigPanelProps { config: ImageDisplayConfig; - onChange: (config: Partial) => void; + onChange?: (config: Partial) => void; + onConfigChange?: (config: Partial) => void; } /** * ImageDisplay 설정 패널 - * 컴포넌트의 설정값들을 편집할 수 있는 UI 제공 */ export const ImageDisplayConfigPanel: React.FC = ({ config, onChange, + onConfigChange, }) => { const handleChange = (key: keyof ImageDisplayConfig, value: any) => { - onChange({ [key]: value }); + const update = { ...config, [key]: value }; + onChange?.(update); + onConfigChange?.(update); }; return (
-
- image-display 설정 +
이미지 표시 설정
+ + {/* 이미지 URL */} +
+ + handleChange("imageUrl", e.target.value)} + placeholder="https://..." + className="h-8 text-xs" + /> +

+ 데이터 바인딩 값이 없을 때 표시할 기본 이미지 +

- {/* file 관련 설정 */} + {/* 대체 텍스트 */}
- + + handleChange("altText", e.target.value)} + placeholder="이미지 설명" + className="h-8 text-xs" + /> +
+ + {/* 이미지 맞춤 */} +
+ + +
+ + {/* 테두리 둥글기 */} +
+ + handleChange("borderRadius", parseInt(e.target.value) || 0)} + className="h-8 text-xs" + /> +
+ + {/* 배경 색상 */} +
+ +
+ handleChange("backgroundColor", e.target.value)} + className="h-8 w-8 cursor-pointer rounded border" + /> + handleChange("backgroundColor", e.target.value)} + className="h-8 flex-1 text-xs" + /> +
+
+ + {/* 플레이스홀더 */} +
+ handleChange("placeholder", e.target.value)} + placeholder="이미지 없음" + className="h-8 text-xs" />
- {/* 공통 설정 */} -
- + {/* 테두리 표시 */} +
handleChange("disabled", checked)} + id="showBorder" + checked={config.showBorder ?? true} + onCheckedChange={(checked) => handleChange("showBorder", checked)} /> +
-
- - handleChange("required", checked)} - /> -
- -
- + {/* 읽기 전용 */} +
handleChange("readonly", checked)} /> + +
+ + {/* 필수 입력 */} +
+ handleChange("required", checked)} + /> +
); diff --git a/frontend/lib/registry/components/image-display/config.ts b/frontend/lib/registry/components/image-display/config.ts index 268382f0..bae67e14 100644 --- a/frontend/lib/registry/components/image-display/config.ts +++ b/frontend/lib/registry/components/image-display/config.ts @@ -6,9 +6,14 @@ import { ImageDisplayConfig } from "./types"; * ImageDisplay 컴포넌트 기본 설정 */ export const ImageDisplayDefaultConfig: ImageDisplayConfig = { - placeholder: "입력하세요", - - // 공통 기본값 + imageUrl: "", + altText: "이미지", + objectFit: "contain", + borderRadius: 8, + showBorder: true, + backgroundColor: "#f9fafb", + placeholder: "이미지 없음", + disabled: false, required: false, readonly: false, @@ -18,23 +23,31 @@ export const ImageDisplayDefaultConfig: ImageDisplayConfig = { /** * ImageDisplay 컴포넌트 설정 스키마 - * 유효성 검사 및 타입 체크에 사용 */ export const ImageDisplayConfigSchema = { - placeholder: { type: "string", default: "" }, - - // 공통 스키마 + imageUrl: { type: "string", default: "" }, + altText: { type: "string", default: "이미지" }, + objectFit: { + type: "enum", + values: ["contain", "cover", "fill", "none", "scale-down"], + default: "contain", + }, + borderRadius: { type: "number", default: 8 }, + showBorder: { type: "boolean", default: true }, + backgroundColor: { type: "string", default: "#f9fafb" }, + placeholder: { type: "string", default: "이미지 없음" }, + disabled: { type: "boolean", default: false }, required: { type: "boolean", default: false }, readonly: { type: "boolean", default: false }, - variant: { - type: "enum", - values: ["default", "outlined", "filled"], - default: "default" + variant: { + type: "enum", + values: ["default", "outlined", "filled"], + default: "default", }, - size: { - type: "enum", - values: ["sm", "md", "lg"], - default: "md" + size: { + type: "enum", + values: ["sm", "md", "lg"], + default: "md", }, }; diff --git a/frontend/lib/registry/components/image-display/index.ts b/frontend/lib/registry/components/image-display/index.ts index ddb38f95..ffa5712a 100644 --- a/frontend/lib/registry/components/image-display/index.ts +++ b/frontend/lib/registry/components/image-display/index.ts @@ -21,7 +21,13 @@ export const ImageDisplayDefinition = createComponentDefinition({ webType: "file", component: ImageDisplayWrapper, defaultConfig: { - placeholder: "입력하세요", + imageUrl: "", + altText: "이미지", + objectFit: "contain", + borderRadius: 8, + showBorder: true, + backgroundColor: "#f9fafb", + placeholder: "이미지 없음", }, defaultSize: { width: 200, height: 200 }, configPanel: ImageDisplayConfigPanel, diff --git a/frontend/lib/registry/components/image-display/types.ts b/frontend/lib/registry/components/image-display/types.ts index f2b6971d..e882ebe4 100644 --- a/frontend/lib/registry/components/image-display/types.ts +++ b/frontend/lib/registry/components/image-display/types.ts @@ -6,20 +6,24 @@ import { ComponentConfig } from "@/types/component"; * ImageDisplay 컴포넌트 설정 타입 */ export interface ImageDisplayConfig extends ComponentConfig { - // file 관련 설정 + // 이미지 관련 설정 + imageUrl?: string; + altText?: string; + objectFit?: "contain" | "cover" | "fill" | "none" | "scale-down"; + borderRadius?: number; + showBorder?: boolean; + backgroundColor?: string; placeholder?: string; - + // 공통 설정 disabled?: boolean; required?: boolean; readonly?: boolean; - placeholder?: string; - helperText?: string; - + // 스타일 관련 variant?: "default" | "outlined" | "filled"; size?: "sm" | "md" | "lg"; - + // 이벤트 관련 onChange?: (value: any) => void; onFocus?: () => void; @@ -37,7 +41,7 @@ export interface ImageDisplayProps { config?: ImageDisplayConfig; className?: string; style?: React.CSSProperties; - + // 이벤트 핸들러 onChange?: (value: any) => void; onFocus?: () => void; diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index c806e0df..6d55b650 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -28,6 +28,7 @@ import { apiClient } from "@/lib/api/client"; import { allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule"; import { useCascadingDropdown } from "@/hooks/useCascadingDropdown"; import { CascadingDropdownConfig } from "@/types/screen-management"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { UniversalFormModalComponentProps, @@ -1835,11 +1836,11 @@ export function UniversalFormModalComponent({ case "date": return ( - onChangeHandler(e.target.value)} + onChange={onChangeHandler} + placeholder={field.placeholder || "날짜를 선택하세요"} disabled={isDisabled} readOnly={field.readOnly} /> @@ -1847,13 +1848,14 @@ export function UniversalFormModalComponent({ case "datetime": return ( - onChangeHandler(e.target.value)} + onChange={onChangeHandler} + placeholder={field.placeholder || "날짜/시간을 선택하세요"} disabled={isDisabled} readOnly={field.readOnly} + includeTime /> ); diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index 371814b5..c00c1b1f 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -1481,9 +1481,9 @@ export const ButtonPrimaryComponent: React.FC = ({ )}
- {/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */} + {/* 확인 다이얼로그 */} - + {getConfirmTitle()} {getConfirmMessage()} diff --git a/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx b/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx index e8b0dba9..58554c9d 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx @@ -247,14 +247,12 @@ export const FileManagerModal: React.FC = ({
- {/* 파일 업로드 영역 - 높이 축소 */} - {!isDesignMode && ( + {/* 파일 업로드 영역 - readonly/disabled이면 숨김 */} + {!isDesignMode && !config.readonly && !config.disabled && (
{ - if (!config.disabled && !isDesignMode) { - fileInputRef.current?.click(); - } + fileInputRef.current?.click(); }} onDragOver={handleDragOver} onDragLeave={handleDragLeave} @@ -267,7 +265,6 @@ export const FileManagerModal: React.FC = ({ accept={config.accept} onChange={handleFileInputChange} className="hidden" - disabled={config.disabled} /> {uploading ? ( @@ -286,8 +283,8 @@ export const FileManagerModal: React.FC = ({ {/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
- {/* 좌측: 이미지 미리보기 (확대/축소 가능) */} -
+ {/* 좌측: 이미지 미리보기 (확대/축소 가능) - showPreview가 false면 숨김 */} + {(config.showPreview !== false) &&
{/* 확대/축소 컨트롤 */} {selectedFile && previewImageUrl && (
@@ -369,10 +366,10 @@ export const FileManagerModal: React.FC = ({ {selectedFile.realFileName}
)} -
+
} - {/* 우측: 파일 목록 (고정 너비) */} -
+ {/* 우측: 파일 목록 - showFileList가 false면 숨김, showPreview가 false면 전체 너비 */} + {(config.showFileList !== false) &&

업로드된 파일

@@ -404,7 +401,7 @@ export const FileManagerModal: React.FC = ({ )}

- {formatFileSize(file.fileSize)} • {file.fileExt.toUpperCase()} + {config.showFileSize !== false && <>{formatFileSize(file.fileSize)} • }{file.fileExt.toUpperCase()}

@@ -434,19 +431,21 @@ export const FileManagerModal: React.FC = ({ > - - {!isDesignMode && ( + {config.allowDownload !== false && ( + + )} + {!isDesignMode && config.allowDelete !== false && (
)}
-
+
}
@@ -487,8 +486,8 @@ export const FileManagerModal: React.FC = ({ file={viewerFile} isOpen={isViewerOpen} onClose={handleViewerClose} - onDownload={onFileDownload} - onDelete={!isDesignMode ? onFileDelete : undefined} + onDownload={config.allowDownload !== false ? onFileDownload : undefined} + onDelete={!isDesignMode && config.allowDelete !== false ? onFileDelete : undefined} /> ); diff --git a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx index fc39458a..de55bf2a 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx @@ -105,6 +105,8 @@ const FileUploadComponent: React.FC = ({ const [forceUpdate, setForceUpdate] = useState(0); const [representativeImageUrl, setRepresentativeImageUrl] = useState(null); const fileInputRef = useRef(null); + // objid 기반으로 파일이 로드되었는지 추적 (다른 이펙트가 덮어쓰지 않도록 방지) + const filesLoadedFromObjidRef = useRef(false); // 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리 const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_')); @@ -150,6 +152,7 @@ const FileUploadComponent: React.FC = ({ if (isRecordMode || !recordId) { setUploadedFiles([]); setRepresentativeImageUrl(null); + filesLoadedFromObjidRef.current = false; } } else if (prevIsRecordModeRef.current === null) { // 초기 마운트 시 모드 저장 @@ -191,63 +194,68 @@ const FileUploadComponent: React.FC = ({ }, [component.id, getUniqueKey, recordId, isRecordMode]); // 레코드별 고유 키 변경 시 재실행 // 🔑 수정 모드: formData[columnName]에 저장된 objid로 이미지 로드 - // 🆕 formData 전체가 아닌 특정 컬럼 값만 의존하도록 수정 (다른 컴포넌트 영향 방지) + // 콤마로 구분된 다중 objid도 처리 (예: "123,456") const imageObjidFromFormData = formData?.[columnName]; useEffect(() => { - // 이미지 objid가 있고, 숫자 문자열인 경우에만 처리 - if (imageObjidFromFormData && /^\d+$/.test(String(imageObjidFromFormData))) { - const objidStr = String(imageObjidFromFormData); - - // 이미 같은 objid의 파일이 로드되어 있으면 스킵 - const alreadyLoaded = uploadedFiles.some(f => String(f.objid) === objidStr); - if (alreadyLoaded) { - return; - } - - // 🔑 실제 파일 정보 조회 (previewUrl 제거 - apiClient blob 다운로드 방식으로 통일) - (async () => { - try { - const fileInfoResponse = await getFileInfoByObjid(objidStr); + if (!imageObjidFromFormData) return; + + const rawValue = String(imageObjidFromFormData); + // 콤마 구분 다중 objid 또는 단일 objid 모두 처리 + const objids = rawValue.split(',').map(s => s.trim()).filter(s => /^\d+$/.test(s)); + + if (objids.length === 0) return; + + // 모든 objid가 이미 로드되어 있으면 스킵 + const allLoaded = objids.every(id => uploadedFiles.some(f => String(f.objid) === id)); + if (allLoaded) return; + + (async () => { + try { + const loadedFiles: FileInfo[] = []; + + for (const objid of objids) { + // 이미 로드된 파일은 스킵 + if (uploadedFiles.some(f => String(f.objid) === objid)) continue; + + const fileInfoResponse = await getFileInfoByObjid(objid); if (fileInfoResponse.success && fileInfoResponse.data) { const { realFileName, fileSize, fileExt, regdate, isRepresentative } = fileInfoResponse.data; - const fileInfo = { - objid: objidStr, - realFileName: realFileName, - fileExt: fileExt, - fileSize: fileSize, - filePath: getFilePreviewUrl(objidStr), - regdate: regdate, + loadedFiles.push({ + objid, + realFileName, + fileExt, + fileSize, + filePath: getFilePreviewUrl(objid), + regdate, isImage: true, - isRepresentative: isRepresentative, - }; - - setUploadedFiles([fileInfo]); - // representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨 + isRepresentative, + } as FileInfo); } else { // 파일 정보 조회 실패 시 최소 정보로 추가 - console.warn("🖼️ [FileUploadComponent] 파일 정보 조회 실패, 최소 정보 사용"); - const minimalFileInfo = { - objid: objidStr, - realFileName: `image_${objidStr}.jpg`, + loadedFiles.push({ + objid, + realFileName: `file_${objid}`, fileExt: '.jpg', fileSize: 0, - filePath: getFilePreviewUrl(objidStr), + filePath: getFilePreviewUrl(objid), regdate: new Date().toISOString(), isImage: true, - }; - - setUploadedFiles([minimalFileInfo]); - // representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨 + } as FileInfo); } - } catch (error) { - console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error); } - })(); - } - }, [imageObjidFromFormData, columnName, component.id]); // 🆕 formData 대신 특정 컬럼 값만 의존 + + if (loadedFiles.length > 0) { + setUploadedFiles(loadedFiles); + filesLoadedFromObjidRef.current = true; + } + } catch (error) { + console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error); + } + })(); + }, [imageObjidFromFormData, columnName, component.id]); // 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너 // 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분 @@ -365,6 +373,10 @@ const FileUploadComponent: React.FC = ({ ...file, })); + // 서버에서 0개 반환 + objid 기반 로딩이 이미 완료된 경우 덮어쓰지 않음 + if (formattedFiles.length === 0 && filesLoadedFromObjidRef.current) { + return false; + } // 🔄 localStorage의 기존 파일과 서버 파일 병합 (레코드별 고유 키 사용) let finalFiles = formattedFiles; @@ -427,14 +439,19 @@ const FileUploadComponent: React.FC = ({ return; // DB 로드 성공 시 localStorage 무시 } - // 🆕 등록 모드(새 레코드)인 경우 fallback 로드도 스킵 - 항상 빈 상태 유지 + // objid 기반으로 이미 파일이 로드된 경우 빈 데이터로 덮어쓰지 않음 + if (filesLoadedFromObjidRef.current) { + return; + } + + // 등록 모드(새 레코드)인 경우 fallback 로드도 스킵 - 항상 빈 상태 유지 if (!isRecordMode || !recordId) { return; } // DB 로드 실패 시에만 기존 로직 사용 (하위 호환성) - // 전역 상태에서 최신 파일 정보 가져오기 (🆕 고유 키 사용) + // 전역 상태에서 최신 파일 정보 가져오기 (고유 키 사용) const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; const uniqueKeyForFallback = getUniqueKey(); const globalFiles = globalFileState[uniqueKeyForFallback] || globalFileState[component.id] || []; @@ -442,6 +459,10 @@ const FileUploadComponent: React.FC = ({ // 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성) const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles; + // 빈 데이터로 기존 파일을 덮어쓰지 않음 + if (currentFiles.length === 0) { + return; + } // 최신 파일과 현재 파일 비교 if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) { @@ -1147,8 +1168,8 @@ const FileUploadComponent: React.FC = ({ file={viewerFile} isOpen={isViewerOpen} onClose={handleViewerClose} - onDownload={handleFileDownload} - onDelete={!isDesignMode ? handleFileDelete : undefined} + onDownload={safeComponentConfig.allowDownload !== false ? handleFileDownload : undefined} + onDelete={!isDesignMode && safeComponentConfig.allowDelete !== false ? handleFileDelete : undefined} /> {/* 파일 관리 모달 */} diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 30584fc4..ebdf9d2b 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -2172,7 +2172,7 @@ export const TableListComponent: React.FC = ({ const handleRowClick = (row: any, index: number, e: React.MouseEvent) => { // 체크박스 클릭은 무시 (이미 handleRowSelection에서 처리됨) const target = e.target as HTMLElement; - if (target.closest('input[type="checkbox"]')) { + if (target.closest('input[type="checkbox"]') || target.closest('button[role="checkbox"]')) { return; } @@ -2198,35 +2198,32 @@ export const TableListComponent: React.FC = ({ } }; - // 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택) + // 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택/해제 토글) const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => { e.stopPropagation(); setFocusedCell({ rowIndex, colIndex }); - // 테이블 컨테이너에 포커스 설정 (키보드 이벤트 수신용) tableContainerRef.current?.focus(); - // 🆕 분할 패널 내에서 셀 클릭 시에도 해당 행 선택 처리 - // filteredData에서 해당 행의 데이터 가져오기 const row = filteredData[rowIndex]; if (!row) return; + // 체크박스 컬럼은 Checkbox의 onCheckedChange에서 이미 처리되므로 스킵 + const column = visibleColumns[colIndex]; + if (column?.columnName === "__checkbox__") return; + const rowKey = getRowKey(row, rowIndex); const isCurrentlySelected = selectedRows.has(rowKey); - // 분할 패널 컨텍스트가 있고, 좌측 화면인 경우에만 행 선택 및 데이터 전달 const effectiveSplitPosition = splitPanelPosition || currentSplitPosition; if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { - // 이미 선택된 행과 다른 행을 클릭한 경우에만 처리 + // 분할 패널 좌측: 단일 행 선택 모드 if (!isCurrentlySelected) { - // 기존 선택 해제하고 새 행 선택 setSelectedRows(new Set([rowKey])); setIsAllSelected(false); - // 분할 패널 컨텍스트에 데이터 저장 splitPanelContext.setSelectedLeftData(row); - // onSelectedRowsChange 콜백 호출 if (onSelectedRowsChange) { onSelectedRowsChange([rowKey], [row], sortColumn || undefined, sortDirection); } @@ -2234,6 +2231,17 @@ export const TableListComponent: React.FC = ({ onFormDataChange({ selectedRows: [rowKey], selectedRowsData: [row] }); } } + } else { + // 일반 모드: 행 선택/해제 토글 + handleRowSelection(rowKey, !isCurrentlySelected); + + if (splitPanelContext && effectiveSplitPosition === "left") { + if (!isCurrentlySelected) { + splitPanelContext.setSelectedLeftData(row); + } else { + splitPanelContext.setSelectedLeftData(null); + } + } } }; @@ -6309,6 +6317,21 @@ export const TableListComponent: React.FC = ({ ); } + // 날짜 타입: 캘린더 피커 + const isDateType = colMeta?.inputType === "date" || colMeta?.inputType === "datetime"; + if (isDateType) { + const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker"); + return ( + + ); + } + // 일반 입력 필드 return ( { if (!currentTable?.getColumnUniqueValues || activeFilters.length === 0) { return; @@ -450,26 +450,37 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table return; } - const newOptions: Record> = { ...selectOptions }; + const loadedOptions: Record> = {}; + let hasNewOptions = false; for (const filter of selectFilters) { - // 이미 로드된 옵션이 있으면 스킵 (초기값 유지) - if (newOptions[filter.columnName] && newOptions[filter.columnName].length > 0) { - continue; - } - try { const options = await currentTable.getColumnUniqueValues(filter.columnName); - newOptions[filter.columnName] = options; + if (options && options.length > 0) { + loadedOptions[filter.columnName] = options; + hasNewOptions = true; + } } catch (error) { console.error("❌ [TableSearchWidget] select 옵션 로드 실패:", filter.columnName, error); } } - setSelectOptions(newOptions); + + if (hasNewOptions) { + setSelectOptions((prev) => { + // 이미 로드된 옵션은 유지, 새로 로드된 옵션만 병합 + const merged = { ...prev }; + for (const [key, value] of Object.entries(loadedOptions)) { + if (!merged[key] || merged[key].length === 0) { + merged[key] = value; + } + } + return merged; + }); + } }; loadSelectOptions(); - }, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues]); // dataCount 제거, tableName으로 변경 + }, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues, currentTable?.dataCount]); // 높이 변화 감지 및 알림 (실제 화면에서만) useEffect(() => { @@ -722,7 +733,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table - +
{uniqueOptions.length === 0 ? (
옵션 없음
@@ -739,7 +750,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table onCheckedChange={(checked) => handleMultiSelectChange(option.value, checked as boolean)} onClick={(e) => e.stopPropagation()} /> - {option.label} + {option.label}
))}