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.
This commit is contained in:
parent
27be48464a
commit
17d4cc297c
|
|
@ -1288,7 +1288,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
|
||||
{/* 모달 닫기 확인 다이얼로그 */}
|
||||
<AlertDialog open={showCloseConfirm} onOpenChange={setShowCloseConfirm}>
|
||||
<AlertDialogContent className="!z-[1100] max-w-[95vw] sm:max-w-[400px]">
|
||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-base sm:text-lg">
|
||||
화면을 닫으시겠습니까?
|
||||
|
|
|
|||
|
|
@ -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<FormDatePickerProps> = ({
|
||||
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 (
|
||||
<Popover open={isOpen} onOpenChange={(open) => { if (!open) { setIsOpen(false); setIsTyping(false); } }}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
id={id}
|
||||
className={cn(
|
||||
"border-input bg-background flex h-10 w-full cursor-pointer items-center rounded-md border px-3",
|
||||
(disabled || readOnly) && "cursor-not-allowed opacity-50",
|
||||
!selectedDate && !isTyping && "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => { if (!disabled && !readOnly) setIsOpen(true); }}
|
||||
>
|
||||
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={isTyping ? typingValue : (formatDisplayValue() || "")}
|
||||
placeholder={placeholder || "날짜를 선택하세요"}
|
||||
disabled={disabled || readOnly}
|
||||
onChange={(e) => 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 && (
|
||||
<X
|
||||
className="text-muted-foreground hover:text-foreground ml-auto h-3.5 w-3.5 shrink-0 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClear();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleSetToday}>
|
||||
오늘
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleClear}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{viewMode === "year" ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm font-medium">{yearRangeStart} - {yearRangeStart + 11}</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||
<Button
|
||||
key={year}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
|
||||
setViewMode("month");
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : viewMode === "month" ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{currentMonth.getFullYear()}년
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||
<Button
|
||||
key={month}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
|
||||
setViewMode("calendar");
|
||||
}}
|
||||
>
|
||||
{month + 1}월
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
|
||||
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid grid-cols-7 gap-1">
|
||||
{allDays.map((date, index) => {
|
||||
if (!date) return <div key={index} className="p-2" />;
|
||||
|
||||
const isCurrentMonth = isSameMonth(date, currentMonth);
|
||||
const isSelected = selectedDate ? isSameDay(date, selectedDate) : false;
|
||||
const isTodayDate = isToday(date);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={date.toISOString()}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 w-8 p-0 text-xs",
|
||||
!isCurrentMonth && "text-muted-foreground opacity-50",
|
||||
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
isTodayDate && !isSelected && "border-primary border",
|
||||
)}
|
||||
onClick={() => handleDateClick(date)}
|
||||
disabled={!isCurrentMonth}
|
||||
>
|
||||
{format(date, "d")}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{includeTime && viewMode === "calendar" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">시간:</span>
|
||||
<input
|
||||
type="time"
|
||||
value={timeValue}
|
||||
onChange={(e) => handleTimeChange(e.target.value)}
|
||||
className="border-input h-8 rounded-md border px-2 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<HTMLInputElement>) => void;
|
||||
inputRef?: React.RefObject<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export const InlineCellDatePicker: React.FC<InlineCellDatePickerProps> = ({
|
||||
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<HTMLInputElement>(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 (
|
||||
<Popover open={isOpen} onOpenChange={handlePopoverClose}>
|
||||
<PopoverTrigger asChild>
|
||||
<input
|
||||
ref={actualInputRef as any}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={value}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
<div className="p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleSetToday}>
|
||||
오늘
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleClear}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{viewMode === "year" ? (
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div className="text-xs font-medium">
|
||||
{yearRangeStart} - {yearRangeStart + 11}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||
<Button
|
||||
key={year}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 text-xs",
|
||||
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
|
||||
setViewMode("month");
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : viewMode === "month" ? (
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-0.5 text-xs font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{currentMonth.getFullYear()}년
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||
<Button
|
||||
key={month}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 text-xs",
|
||||
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
|
||||
setViewMode("calendar");
|
||||
}}
|
||||
>
|
||||
{month + 1}월
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-0.5 text-xs font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 grid grid-cols-7 gap-0.5">
|
||||
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
|
||||
<div key={day} className="text-muted-foreground p-1 text-center text-[10px] font-medium">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{allDays.map((date, index) => {
|
||||
if (!date) return <div key={index} className="p-1" />;
|
||||
|
||||
const isCurrentMonth = isSameMonth(date, currentMonth);
|
||||
const isSelected = selectedDate ? isSameDay(date, selectedDate) : false;
|
||||
const isTodayDate = isToday(date);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={date.toISOString()}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0 text-[11px]",
|
||||
!isCurrentMonth && "text-muted-foreground opacity-50",
|
||||
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
isTodayDate && !isSelected && "border-primary border",
|
||||
)}
|
||||
onClick={() => handleDateClick(date)}
|
||||
disabled={!isCurrentMonth}
|
||||
>
|
||||
{format(date, "d")}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
|
@ -34,6 +34,8 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ 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<DateRangeValue>(value || {});
|
||||
|
|
@ -43,6 +45,7 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
|||
if (isOpen) {
|
||||
setTempValue(value || {});
|
||||
setSelectingType("from");
|
||||
setViewMode("calendar");
|
||||
}
|
||||
}, [isOpen, value]);
|
||||
|
||||
|
|
@ -234,60 +237,150 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 월 네비게이션 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm font-medium">{format(currentMonth, "yyyy년 MM월", { locale: ko })}</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 요일 헤더 */}
|
||||
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
|
||||
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 날짜 그리드 */}
|
||||
<div className="mb-4 grid grid-cols-7 gap-1">
|
||||
{allDays.map((date, index) => {
|
||||
if (!date) {
|
||||
return <div key={index} className="p-2" />;
|
||||
}
|
||||
|
||||
const isCurrentMonth = isSameMonth(date, currentMonth);
|
||||
const isSelected = isRangeStart(date) || isRangeEnd(date);
|
||||
const isInRangeDate = isInRange(date);
|
||||
const isTodayDate = isToday(date);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={date.toISOString()}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 w-8 p-0 text-xs",
|
||||
!isCurrentMonth && "text-muted-foreground opacity-50",
|
||||
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
isInRangeDate && !isSelected && "bg-muted",
|
||||
isTodayDate && !isSelected && "border-primary border",
|
||||
selectingType === "from" && "hover:bg-primary/20",
|
||||
selectingType === "to" && "hover:bg-secondary/20",
|
||||
)}
|
||||
onClick={() => handleDateClick(date)}
|
||||
disabled={!isCurrentMonth}
|
||||
>
|
||||
{format(date, "d")}
|
||||
{viewMode === "year" ? (
|
||||
<>
|
||||
{/* 년도 선택 뷰 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-sm font-medium">
|
||||
{yearRangeStart} - {yearRangeStart + 11}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-4 grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||
<Button
|
||||
key={year}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
|
||||
setViewMode("month");
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : viewMode === "month" ? (
|
||||
<>
|
||||
{/* 월 선택 뷰 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{currentMonth.getFullYear()}년
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-4 grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||
<Button
|
||||
key={month}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
|
||||
setViewMode("calendar");
|
||||
}}
|
||||
>
|
||||
{month + 1}월
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 월 네비게이션 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 요일 헤더 */}
|
||||
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
|
||||
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 날짜 그리드 */}
|
||||
<div className="mb-4 grid grid-cols-7 gap-1">
|
||||
{allDays.map((date, index) => {
|
||||
if (!date) {
|
||||
return <div key={index} className="p-2" />;
|
||||
}
|
||||
|
||||
const isCurrentMonth = isSameMonth(date, currentMonth);
|
||||
const isSelected = isRangeStart(date) || isRangeEnd(date);
|
||||
const isInRangeDate = isInRange(date);
|
||||
const isTodayDate = isToday(date);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={date.toISOString()}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 w-8 p-0 text-xs",
|
||||
!isCurrentMonth && "text-muted-foreground opacity-50",
|
||||
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
isInRangeDate && !isSelected && "bg-muted",
|
||||
isTodayDate && !isSelected && "border-primary border",
|
||||
selectingType === "from" && "hover:bg-primary/20",
|
||||
selectingType === "to" && "hover:bg-secondary/20",
|
||||
)}
|
||||
onClick={() => handleDateClick(date)}
|
||||
disabled={!isCurrentMonth}
|
||||
>
|
||||
{format(date, "d")}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 선택된 범위 표시 */}
|
||||
{(tempValue.from || tempValue.to) && (
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ export const GroupingPanel: React.FC<Props> = ({
|
|||
전체 해제
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="max-h-[40vh] space-y-2 overflow-y-auto pr-1">
|
||||
{selectedColumns.map((colName, index) => {
|
||||
const col = table?.columns.find(
|
||||
(c) => c.columnName === colName
|
||||
|
|
|
|||
|
|
@ -557,7 +557,7 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
|
|||
전체 해제
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="max-h-[40vh] space-y-2 overflow-y-auto pr-1">
|
||||
{selectedGroupColumns.map((colName, index) => {
|
||||
const col = table?.columns.find((c) => c.columnName === colName);
|
||||
if (!col) return null;
|
||||
|
|
|
|||
|
|
@ -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<WebTypeComponentProps> = ({ 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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Input
|
||||
type={getInputType()}
|
||||
value={formatDateValue(finalValue)}
|
||||
placeholder={placeholder || config?.placeholder || "날짜를 선택하세요..."}
|
||||
onChange={handleChange}
|
||||
disabled={readonly}
|
||||
required={required}
|
||||
className={`h-full w-full ${borderClass}`}
|
||||
min={config?.minDate}
|
||||
max={config?.maxDate}
|
||||
/>
|
||||
<Popover open={isOpen} onOpenChange={(v) => { if (!v) { setIsOpen(false); setIsTyping(false); } }}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"border-input bg-background flex h-full w-full cursor-pointer items-center rounded-md border px-3",
|
||||
readonly && "cursor-not-allowed opacity-50",
|
||||
!selectedDate && !isTyping && "text-muted-foreground",
|
||||
borderClass,
|
||||
)}
|
||||
onClick={() => { if (!readonly) setIsOpen(true); }}
|
||||
>
|
||||
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={isTyping ? typingValue : (formatDisplayValue() || "")}
|
||||
placeholder={placeholder || config?.placeholder || "날짜를 선택하세요..."}
|
||||
disabled={readonly}
|
||||
onChange={(e) => 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 && (
|
||||
<X
|
||||
className="text-muted-foreground hover:text-foreground ml-auto h-3.5 w-3.5 shrink-0 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClear();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleSetToday}>
|
||||
오늘
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleClear}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{viewMode === "year" ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm font-medium">
|
||||
{yearRangeStart} - {yearRangeStart + 11}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-4 grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||
<Button
|
||||
key={year}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
|
||||
setViewMode("month");
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : viewMode === "month" ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{currentMonth.getFullYear()}년
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-4 grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||
<Button
|
||||
key={month}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
|
||||
setViewMode("calendar");
|
||||
}}
|
||||
>
|
||||
{month + 1}월
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
|
||||
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid grid-cols-7 gap-1">
|
||||
{allDays.map((date, index) => {
|
||||
if (!date) return <div key={index} className="p-2" />;
|
||||
|
||||
const isCurrentMonth = isSameMonth(date, currentMonth);
|
||||
const isSelected = selectedDate ? isSameDay(date, selectedDate) : false;
|
||||
const isTodayDate = isToday(date);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={date.toISOString()}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 w-8 p-0 text-xs",
|
||||
!isCurrentMonth && "text-muted-foreground opacity-50",
|
||||
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
isTodayDate && !isSelected && "border-primary border",
|
||||
)}
|
||||
onClick={() => handleDateClick(date)}
|
||||
disabled={!isCurrentMonth}
|
||||
>
|
||||
{format(date, "d")}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* datetime 타입: 시간 입력 */}
|
||||
{isDatetime && viewMode === "calendar" && (
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">시간:</span>
|
||||
<input
|
||||
type="time"
|
||||
value={timeValue}
|
||||
onChange={(e) => handleTimeChange(e.target.value)}
|
||||
className="border-input h-8 rounded-md border px-2 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
DateWidget.displayName = "DateWidget";
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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<CategoryColumn[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(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<string, CategoryColumn[]>();
|
||||
|
||||
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,
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
{filteredColumns.length === 0 && searchQuery ? (
|
||||
<div className="text-muted-foreground py-4 text-center text-xs">
|
||||
'{searchQuery}'에 대한 검색 결과가 없습니다
|
||||
</div>
|
||||
) : null}
|
||||
{filteredColumns.map((column) => {
|
||||
const uniqueKey = `${column.tableName}.${column.columnName}`;
|
||||
const isSelected = selectedColumn === uniqueKey; // 테이블명.컬럼명으로 비교
|
||||
return (
|
||||
<div
|
||||
key={uniqueKey}
|
||||
onClick={() => 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"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderTree
|
||||
className={`h-4 w-4 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold">{column.columnLabel || column.columnName}</h4>
|
||||
<p className="text-muted-foreground text-xs">{column.tableLabel || column.tableName}</p>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
{column.valueCount !== undefined ? `${column.valueCount}개` : "..."}
|
||||
</span>
|
||||
{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 (
|
||||
<div key={group.tableName} className="space-y-1.5">
|
||||
{group.columns.map((column) => {
|
||||
const uniqueKey = `${column.tableName}.${column.columnName}`;
|
||||
const isSelected = selectedColumn === uniqueKey;
|
||||
return (
|
||||
<div
|
||||
key={uniqueKey}
|
||||
onClick={() => 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"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderTree
|
||||
className={`h-4 w-4 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold">{column.columnLabel || column.columnName}</h4>
|
||||
<p className="text-muted-foreground text-xs">{column.tableLabel || column.tableName}</p>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
{column.valueCount !== undefined ? `${column.valueCount}개` : "..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={group.tableName} className="overflow-hidden rounded-lg border">
|
||||
{/* 드롭다운 헤더 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(group.tableName)) {
|
||||
next.delete(group.tableName);
|
||||
} else {
|
||||
next.add(group.tableName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left transition-colors ${
|
||||
hasSelectedInGroup ? "bg-primary/5" : "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-3.5 w-3.5 shrink-0 transition-transform duration-200 ${
|
||||
isExpanded ? "rotate-90" : ""
|
||||
} ${hasSelectedInGroup ? "text-primary" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<span className={`flex-1 text-xs font-semibold ${hasSelectedInGroup ? "text-primary" : ""}`}>
|
||||
{group.tableLabel}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{group.columns.length}개 컬럼 / {totalValues}개 값
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 펼쳐진 컬럼 목록 */}
|
||||
{isExpanded && (
|
||||
<div className="space-y-1 border-t px-2 py-2">
|
||||
{group.columns.map((column) => {
|
||||
const uniqueKey = `${column.tableName}.${column.columnName}`;
|
||||
const isSelected = selectedColumn === uniqueKey;
|
||||
return (
|
||||
<div
|
||||
key={uniqueKey}
|
||||
onClick={() => 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"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderTree
|
||||
className={`h-3.5 w-3.5 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<span className="flex-1 text-xs">{column.columnLabel || column.columnName}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{column.valueCount !== undefined ? `${column.valueCount}개` : "..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-[999] bg-black/80",
|
||||
"fixed inset-0 z-[1050] bg-black/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
|
|||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background fixed top-[50%] left-[50%] z-[1000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||
"bg-background fixed top-[50%] left-[50%] z-[1100] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -10,14 +10,13 @@
|
|||
* - range 옵션: 범위 선택 (시작~종료)
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useCallback, useMemo, useState } from "react";
|
||||
import { format, parse, isValid } from "date-fns";
|
||||
import React, { forwardRef, useCallback, useMemo, useState, useEffect } from "react";
|
||||
import { format, parse, isValid, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isSameDay, isToday as isTodayFn } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import { Calendar as CalendarIcon, Clock } from "lucide-react";
|
||||
import { Calendar as CalendarIcon, Clock, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { V2DateProps, V2DateType } from "@/types/v2-components";
|
||||
|
|
@ -60,11 +59,24 @@ function formatDate(date: Date | undefined, formatStr: string): string {
|
|||
return format(date, dateFnsFormat);
|
||||
}
|
||||
|
||||
// YYYYMMDD 또는 YYYY-MM-DD 문자열 → 유효한 Date 객체 반환 (유효하지 않으면 null)
|
||||
function parseManualDateInput(raw: string): Date | null {
|
||||
const digits = raw.replace(/\D/g, "");
|
||||
if (digits.length !== 8) return null;
|
||||
const y = parseInt(digits.slice(0, 4), 10);
|
||||
const m = parseInt(digits.slice(4, 6), 10) - 1;
|
||||
const d = parseInt(digits.slice(6, 8), 10);
|
||||
const date = new Date(y, m, d);
|
||||
if (date.getFullYear() !== y || date.getMonth() !== m || date.getDate() !== d) return null;
|
||||
if (y < 1900 || y > 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<HTMLInputElement>(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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={(v) => { if (!v) { setOpen(false); setIsTyping(false); } }}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
<div
|
||||
ref={ref}
|
||||
variant="outline"
|
||||
disabled={disabled || readonly}
|
||||
className={cn(
|
||||
"h-full w-full justify-start text-left font-normal",
|
||||
!displayText && "text-muted-foreground",
|
||||
"border-input bg-background flex h-full w-full cursor-pointer items-center rounded-md border px-3",
|
||||
(disabled || readonly) && "cursor-not-allowed opacity-50",
|
||||
className,
|
||||
)}
|
||||
onClick={() => { if (!disabled && !readonly) setOpen(true); }}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||
{displayText || placeholder}
|
||||
</Button>
|
||||
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={isTyping ? typingValue : (displayText || "")}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled || readonly}
|
||||
onChange={(e) => 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",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={handleSelect}
|
||||
initialFocus
|
||||
locale={ko}
|
||||
disabled={(date) => {
|
||||
if (minDateObj && date < minDateObj) return true;
|
||||
if (maxDateObj && date > maxDateObj) return true;
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2 p-3 pt-0">
|
||||
{showToday && (
|
||||
<Button variant="outline" size="sm" onClick={handleToday}>
|
||||
오늘
|
||||
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
{showToday && (
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleToday}>
|
||||
오늘
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleClear}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{viewMode === "year" ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm font-medium">{yearRangeStart} - {yearRangeStart + 11}</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||
<Button
|
||||
key={year}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
|
||||
)}
|
||||
onClick={() => { setCurrentMonth(new Date(year, currentMonth.getMonth(), 1)); setViewMode("month"); }}
|
||||
>
|
||||
{year}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : viewMode === "month" ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button type="button" className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors" onClick={() => { setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12); setViewMode("year"); }}>
|
||||
{currentMonth.getFullYear()}년
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||
<Button
|
||||
key={month}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
|
||||
)}
|
||||
onClick={() => { setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1)); setViewMode("calendar"); }}
|
||||
>
|
||||
{month + 1}월
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button type="button" className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors" onClick={() => { setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12); setViewMode("year"); }}>
|
||||
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||
{["월", "화", "수", "목", "금", "토", "일"].map((d) => (
|
||||
<div key={d} className="text-muted-foreground p-2 text-center text-xs font-medium">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{allDays.map((d, idx) => {
|
||||
if (!d) return <div key={idx} className="p-2" />;
|
||||
const isCur = isSameMonth(d, currentMonth);
|
||||
const isSel = date ? isSameDay(d, date) : false;
|
||||
const isT = isTodayFn(d);
|
||||
return (
|
||||
<Button
|
||||
key={d.toISOString()}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 w-8 p-0 text-xs",
|
||||
!isCur && "text-muted-foreground opacity-50",
|
||||
isSel && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
isT && !isSel && "border-primary border",
|
||||
)}
|
||||
onClick={() => handleDateClick(d)}
|
||||
disabled={!isCur}
|
||||
>
|
||||
{format(d, "d")}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={handleClear}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
@ -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 (
|
||||
<Popover open={open} onOpenChange={(v) => { if (!v) { setIsTyping(false); } onOpenChange(v); }}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"border-input bg-background flex h-full flex-1 cursor-pointer items-center rounded-md border px-3",
|
||||
(disabled || readonly) && "cursor-not-allowed opacity-50",
|
||||
!displayValue && !isTyping && "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => { if (!disabled && !readonly) onOpenChange(true); }}
|
||||
>
|
||||
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={isTyping ? typingValue : (displayValue || "")}
|
||||
placeholder={label}
|
||||
disabled={disabled || readonly}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<div className="p-4">
|
||||
{viewMode === "year" ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}><ChevronLeft className="h-4 w-4" /></Button>
|
||||
<div className="text-sm font-medium">{yearRangeStart} - {yearRangeStart + 11}</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}><ChevronRight className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||
<Button key={year} variant="ghost" size="sm" className={cn("h-9 text-xs", year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary", year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border")}
|
||||
onClick={() => { setCurrentMonth(new Date(year, currentMonth.getMonth(), 1)); setViewMode("month"); }}>{year}</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : viewMode === "month" ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}><ChevronLeft className="h-4 w-4" /></Button>
|
||||
<button type="button" className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors" onClick={() => { setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12); setViewMode("year"); }}>{currentMonth.getFullYear()}년</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}><ChevronRight className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||
<Button key={month} variant="ghost" size="sm" className={cn("h-9 text-xs", month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary", month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border")}
|
||||
onClick={() => { setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1)); setViewMode("calendar"); }}>{month + 1}월</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}><ChevronLeft className="h-4 w-4" /></Button>
|
||||
<button type="button" className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors" onClick={() => { setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12); setViewMode("year"); }}>{format(currentMonth, "yyyy년 MM월", { locale: ko })}</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}><ChevronRight className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||
{["월", "화", "수", "목", "금", "토", "일"].map((d) => (
|
||||
<div key={d} className="text-muted-foreground p-2 text-center text-xs font-medium">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{allDays.map((d, idx) => {
|
||||
if (!d) return <div key={idx} className="p-2" />;
|
||||
const isCur = isSameMonth(d, currentMonth);
|
||||
const isSel = selectedDate ? isSameDay(d, selectedDate) : false;
|
||||
const isT = isTodayFn(d);
|
||||
return (
|
||||
<Button key={d.toISOString()} variant="ghost" size="sm" className={cn("h-8 w-8 p-0 text-xs", !isCur && "text-muted-foreground opacity-50", isSel && "bg-primary text-primary-foreground hover:bg-primary", isT && !isSel && "border-primary border")}
|
||||
onClick={() => onSelect(d)} disabled={!isCur}>{format(d, "d")}</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div ref={ref} className={cn("flex items-center gap-2 h-full", className)}>
|
||||
{/* 시작 날짜 */}
|
||||
<Popover open={openStart} onOpenChange={setOpenStart}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled || readonly}
|
||||
className={cn("h-full flex-1 justify-start text-left font-normal", !value[0] && "text-muted-foreground")}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{value[0] || "시작일"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={startDate}
|
||||
onSelect={handleStartSelect}
|
||||
initialFocus
|
||||
locale={ko}
|
||||
disabled={(date) => {
|
||||
if (minDateObj && date < minDateObj) return true;
|
||||
if (maxDateObj && date > maxDateObj) return true;
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<RangeCalendarPopover open={openStart} onOpenChange={setOpenStart} selectedDate={startDate} onSelect={handleStartSelect} label="시작일" disabled={disabled} readonly={readonly} displayValue={value[0]} />
|
||||
<span className="text-muted-foreground">~</span>
|
||||
|
||||
{/* 종료 날짜 */}
|
||||
<Popover open={openEnd} onOpenChange={setOpenEnd}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled || readonly}
|
||||
className={cn("h-full flex-1 justify-start text-left font-normal", !value[1] && "text-muted-foreground")}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{value[1] || "종료일"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={endDate}
|
||||
onSelect={handleEndSelect}
|
||||
initialFocus
|
||||
locale={ko}
|
||||
disabled={(date) => {
|
||||
if (minDateObj && date < minDateObj) return true;
|
||||
if (maxDateObj && date > maxDateObj) return true;
|
||||
// 시작일보다 이전 날짜는 선택 불가
|
||||
if (startDate && date < startDate) return true;
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<RangeCalendarPopover open={openEnd} onOpenChange={setOpenEnd} selectedDate={endDate} onSelect={handleEndSelect} label="종료일" disabled={disabled} readonly={readonly} displayValue={value[1]} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1328,9 +1328,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
|
||||
{/* 확인 다이얼로그 */}
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialogContent className="z-[99999]">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{getConfirmMessage()}</AlertDialogDescription>
|
||||
|
|
|
|||
|
|
@ -24,29 +24,33 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
|||
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<ImageDisplayComponentProps> = ({
|
|||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
{(component.required || componentConfig.required) && (
|
||||
<span style={{ color: "#ef4444" }}>*</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
|
|
@ -96,43 +102,53 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
|||
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 ? (
|
||||
<img
|
||||
src={component.value || componentConfig.imageUrl}
|
||||
alt={componentConfig.altText || "이미지"}
|
||||
src={imageSrc}
|
||||
alt={altText}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
objectFit: componentConfig.objectFit || "contain",
|
||||
objectFit,
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
if (e.target?.parentElement) {
|
||||
e.target.parentElement.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px; color: #6b7280; font-size: 14px;">
|
||||
<div style="font-size: 24px;">🖼️</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>
|
||||
<div>이미지 로드 실패</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -150,8 +166,22 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
|||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "32px" }}>🖼️</div>
|
||||
<div>이미지 없음</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
<div>{placeholder}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -161,7 +191,6 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
|||
|
||||
/**
|
||||
* ImageDisplay 래퍼 컴포넌트
|
||||
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
||||
*/
|
||||
export const ImageDisplayWrapper: React.FC<ImageDisplayComponentProps> = (props) => {
|
||||
return <ImageDisplayComponent {...props} />;
|
||||
|
|
|
|||
|
|
@ -9,63 +9,166 @@ import { ImageDisplayConfig } from "./types";
|
|||
|
||||
export interface ImageDisplayConfigPanelProps {
|
||||
config: ImageDisplayConfig;
|
||||
onChange: (config: Partial<ImageDisplayConfig>) => void;
|
||||
onChange?: (config: Partial<ImageDisplayConfig>) => void;
|
||||
onConfigChange?: (config: Partial<ImageDisplayConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ImageDisplay 설정 패널
|
||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const ImageDisplayConfigPanel: React.FC<ImageDisplayConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
onConfigChange,
|
||||
}) => {
|
||||
const handleChange = (key: keyof ImageDisplayConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
const update = { ...config, [key]: value };
|
||||
onChange?.(update);
|
||||
onConfigChange?.(update);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">
|
||||
image-display 설정
|
||||
<div className="text-sm font-medium">이미지 표시 설정</div>
|
||||
|
||||
{/* 이미지 URL */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="imageUrl" className="text-xs">
|
||||
기본 이미지 URL
|
||||
</Label>
|
||||
<Input
|
||||
id="imageUrl"
|
||||
value={config.imageUrl || ""}
|
||||
onChange={(e) => handleChange("imageUrl", e.target.value)}
|
||||
placeholder="https://..."
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
데이터 바인딩 값이 없을 때 표시할 기본 이미지
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* file 관련 설정 */}
|
||||
{/* 대체 텍스트 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder">플레이스홀더</Label>
|
||||
<Label htmlFor="altText" className="text-xs">
|
||||
대체 텍스트 (alt)
|
||||
</Label>
|
||||
<Input
|
||||
id="altText"
|
||||
value={config.altText || ""}
|
||||
onChange={(e) => handleChange("altText", e.target.value)}
|
||||
placeholder="이미지 설명"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 이미지 맞춤 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="objectFit" className="text-xs">
|
||||
이미지 맞춤 (Object Fit)
|
||||
</Label>
|
||||
<Select
|
||||
value={config.objectFit || "contain"}
|
||||
onValueChange={(value) => handleChange("objectFit", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="contain">Contain (비율 유지, 전체 표시)</SelectItem>
|
||||
<SelectItem value="cover">Cover (비율 유지, 영역 채움)</SelectItem>
|
||||
<SelectItem value="fill">Fill (영역에 맞춤)</SelectItem>
|
||||
<SelectItem value="none">None (원본 크기)</SelectItem>
|
||||
<SelectItem value="scale-down">Scale Down (축소만)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테두리 둥글기 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderRadius" className="text-xs">
|
||||
테두리 둥글기 (px)
|
||||
</Label>
|
||||
<Input
|
||||
id="borderRadius"
|
||||
type="number"
|
||||
min="0"
|
||||
max="50"
|
||||
value={config.borderRadius ?? 8}
|
||||
onChange={(e) => handleChange("borderRadius", parseInt(e.target.value) || 0)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 배경 색상 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backgroundColor" className="text-xs">
|
||||
배경 색상
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={config.backgroundColor || "#f9fafb"}
|
||||
onChange={(e) => handleChange("backgroundColor", e.target.value)}
|
||||
className="h-8 w-8 cursor-pointer rounded border"
|
||||
/>
|
||||
<Input
|
||||
id="backgroundColor"
|
||||
value={config.backgroundColor || "#f9fafb"}
|
||||
onChange={(e) => handleChange("backgroundColor", e.target.value)}
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 플레이스홀더 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder" className="text-xs">
|
||||
이미지 없을 때 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={config.placeholder || ""}
|
||||
onChange={(e) => handleChange("placeholder", e.target.value)}
|
||||
placeholder="이미지 없음"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공통 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disabled">비활성화</Label>
|
||||
{/* 테두리 표시 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="disabled"
|
||||
checked={config.disabled || false}
|
||||
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
||||
id="showBorder"
|
||||
checked={config.showBorder ?? true}
|
||||
onCheckedChange={(checked) => handleChange("showBorder", checked)}
|
||||
/>
|
||||
<Label htmlFor="showBorder" className="text-xs cursor-pointer">
|
||||
테두리 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="required">필수 입력</Label>
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={config.required || false}
|
||||
onCheckedChange={(checked) => handleChange("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="readonly">읽기 전용</Label>
|
||||
{/* 읽기 전용 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="readonly"
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
<Label htmlFor="readonly" className="text-xs cursor-pointer">
|
||||
읽기 전용
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 필수 입력 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={config.required || false}
|
||||
onCheckedChange={(checked) => handleChange("required", checked)}
|
||||
/>
|
||||
<Label htmlFor="required" className="text-xs cursor-pointer">
|
||||
필수 입력
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
default: "default",
|
||||
},
|
||||
size: {
|
||||
type: "enum",
|
||||
values: ["sm", "md", "lg"],
|
||||
default: "md"
|
||||
default: "md",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -6,15 +6,19 @@ 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";
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Input
|
||||
<FormDatePicker
|
||||
id={fieldKey}
|
||||
type="date"
|
||||
value={value || ""}
|
||||
onChange={(e) => onChangeHandler(e.target.value)}
|
||||
onChange={onChangeHandler}
|
||||
placeholder={field.placeholder || "날짜를 선택하세요"}
|
||||
disabled={isDisabled}
|
||||
readOnly={field.readOnly}
|
||||
/>
|
||||
|
|
@ -1847,13 +1848,14 @@ export function UniversalFormModalComponent({
|
|||
|
||||
case "datetime":
|
||||
return (
|
||||
<Input
|
||||
<FormDatePicker
|
||||
id={fieldKey}
|
||||
type="datetime-local"
|
||||
value={value || ""}
|
||||
onChange={(e) => onChangeHandler(e.target.value)}
|
||||
onChange={onChangeHandler}
|
||||
placeholder={field.placeholder || "날짜/시간을 선택하세요"}
|
||||
disabled={isDisabled}
|
||||
readOnly={field.readOnly}
|
||||
includeTime
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1481,9 +1481,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
|
||||
{/* 확인 다이얼로그 */}
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialogContent className="z-[99999]">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{getConfirmMessage()}</AlertDialogDescription>
|
||||
|
|
|
|||
|
|
@ -247,14 +247,12 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
|||
</DialogHeader>
|
||||
|
||||
<div className="flex h-[75vh] flex-col space-y-3">
|
||||
{/* 파일 업로드 영역 - 높이 축소 */}
|
||||
{!isDesignMode && (
|
||||
{/* 파일 업로드 영역 - readonly/disabled이면 숨김 */}
|
||||
{!isDesignMode && !config.readonly && !config.disabled && (
|
||||
<div
|
||||
className={`cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors ${dragOver ? "border-blue-400 bg-blue-50" : "border-gray-300"} ${config.disabled ? "cursor-not-allowed opacity-50" : "hover:border-gray-400"} ${uploading ? "opacity-75" : ""} `}
|
||||
className={`cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors ${dragOver ? "border-blue-400 bg-blue-50" : "border-gray-300"} hover:border-gray-400 ${uploading ? "opacity-75" : ""} `}
|
||||
onClick={() => {
|
||||
if (!config.disabled && !isDesignMode) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
|
|
@ -267,7 +265,6 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
|||
accept={config.accept}
|
||||
onChange={handleFileInputChange}
|
||||
className="hidden"
|
||||
disabled={config.disabled}
|
||||
/>
|
||||
|
||||
{uploading ? (
|
||||
|
|
@ -286,8 +283,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
|||
|
||||
{/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
{/* 좌측: 이미지 미리보기 (확대/축소 가능) */}
|
||||
<div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border border-gray-200 bg-gray-900">
|
||||
{/* 좌측: 이미지 미리보기 (확대/축소 가능) - showPreview가 false면 숨김 */}
|
||||
{(config.showPreview !== false) && <div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border border-gray-200 bg-gray-900">
|
||||
{/* 확대/축소 컨트롤 */}
|
||||
{selectedFile && previewImageUrl && (
|
||||
<div className="absolute top-3 left-3 z-10 flex items-center gap-1 rounded-lg bg-black/60 p-1">
|
||||
|
|
@ -369,10 +366,10 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
|||
{selectedFile.realFileName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* 우측: 파일 목록 (고정 너비) */}
|
||||
<div className="flex w-[400px] shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200">
|
||||
{/* 우측: 파일 목록 - showFileList가 false면 숨김, showPreview가 false면 전체 너비 */}
|
||||
{(config.showFileList !== false) && <div className={`flex shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200 ${config.showPreview !== false ? "w-[400px]" : "flex-1"}`}>
|
||||
<div className="border-b border-gray-200 bg-gray-50 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-700">업로드된 파일</h3>
|
||||
|
|
@ -404,7 +401,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
|||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatFileSize(file.fileSize)} • {file.fileExt.toUpperCase()}
|
||||
{config.showFileSize !== false && <>{formatFileSize(file.fileSize)} • </>}{file.fileExt.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
|
|
@ -434,19 +431,21 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
|||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFileDownload(file);
|
||||
}}
|
||||
title="다운로드"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</Button>
|
||||
{!isDesignMode && (
|
||||
{config.allowDownload !== false && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFileDownload(file);
|
||||
}}
|
||||
title="다운로드"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
{!isDesignMode && config.allowDelete !== false && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -476,7 +475,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
@ -487,8 +486,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
|||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -105,6 +105,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
const [forceUpdate, setForceUpdate] = useState(0);
|
||||
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<FileUploadComponentProps> = ({
|
|||
if (isRecordMode || !recordId) {
|
||||
setUploadedFiles([]);
|
||||
setRepresentativeImageUrl(null);
|
||||
filesLoadedFromObjidRef.current = false;
|
||||
}
|
||||
} else if (prevIsRecordModeRef.current === null) {
|
||||
// 초기 마운트 시 모드 저장
|
||||
|
|
@ -191,63 +194,68 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
}, [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);
|
||||
if (!imageObjidFromFormData) return;
|
||||
|
||||
// 이미 같은 objid의 파일이 로드되어 있으면 스킵
|
||||
const alreadyLoaded = uploadedFiles.some(f => String(f.objid) === objidStr);
|
||||
if (alreadyLoaded) {
|
||||
return;
|
||||
}
|
||||
const rawValue = String(imageObjidFromFormData);
|
||||
// 콤마 구분 다중 objid 또는 단일 objid 모두 처리
|
||||
const objids = rawValue.split(',').map(s => s.trim()).filter(s => /^\d+$/.test(s));
|
||||
|
||||
// 🔑 실제 파일 정보 조회 (previewUrl 제거 - apiClient blob 다운로드 방식으로 통일)
|
||||
(async () => {
|
||||
try {
|
||||
const fileInfoResponse = await getFileInfoByObjid(objidStr);
|
||||
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<FileUploadComponentProps> = ({
|
|||
...file,
|
||||
}));
|
||||
|
||||
// 서버에서 0개 반환 + objid 기반 로딩이 이미 완료된 경우 덮어쓰지 않음
|
||||
if (formattedFiles.length === 0 && filesLoadedFromObjidRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 🔄 localStorage의 기존 파일과 서버 파일 병합 (레코드별 고유 키 사용)
|
||||
let finalFiles = formattedFiles;
|
||||
|
|
@ -427,14 +439,19 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
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<FileUploadComponentProps> = ({
|
|||
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
|
||||
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<FileUploadComponentProps> = ({
|
|||
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}
|
||||
/>
|
||||
|
||||
{/* 파일 관리 모달 */}
|
||||
|
|
|
|||
|
|
@ -2172,7 +2172,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
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<TableListComponentProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택)
|
||||
// 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택/해제 토글)
|
||||
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<TableListComponentProps> = ({
|
|||
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<TableListComponentProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
// 날짜 타입: 캘린더 피커
|
||||
const isDateType = colMeta?.inputType === "date" || colMeta?.inputType === "datetime";
|
||||
if (isDateType) {
|
||||
const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker");
|
||||
return (
|
||||
<InlineCellDatePicker
|
||||
value={editingValue}
|
||||
onChange={setEditingValue}
|
||||
onSave={saveEditing}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
inputRef={editInputRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 입력 필드
|
||||
return (
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -437,7 +437,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentTable?.tableName, filterMode, screenId, currentTableTabId, JSON.stringify(presetFilters)]);
|
||||
|
||||
// select 옵션 초기 로드 (한 번만 실행, 이후 유지)
|
||||
// select 옵션 로드 (데이터 변경 시 빈 옵션 재조회)
|
||||
useEffect(() => {
|
||||
if (!currentTable?.getColumnUniqueValues || activeFilters.length === 0) {
|
||||
return;
|
||||
|
|
@ -450,26 +450,37 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
return;
|
||||
}
|
||||
|
||||
const newOptions: Record<string, Array<{ label: string; value: string }>> = { ...selectOptions };
|
||||
const loadedOptions: Record<string, Array<{ label: string; value: string }>> = {};
|
||||
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
|
|||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: `${width}px` }} align="start">
|
||||
<PopoverContent className="w-auto p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<div className="max-h-60 overflow-auto">
|
||||
{uniqueOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground px-3 py-2 text-xs">옵션 없음</div>
|
||||
|
|
@ -739,7 +750,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
onCheckedChange={(checked) => handleMultiSelectChange(option.value, checked as boolean)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="text-xs sm:text-sm">{option.label}</span>
|
||||
<span className="truncate text-xs sm:text-sm">{option.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue