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}>
|
<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>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle className="text-base sm:text-lg">
|
<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 [isOpen, setIsOpen] = useState(false);
|
||||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||||
const [selectingType, setSelectingType] = useState<"from" | "to">("from");
|
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 || {});
|
const [tempValue, setTempValue] = useState<DateRangeValue>(value || {});
|
||||||
|
|
@ -43,6 +45,7 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setTempValue(value || {});
|
setTempValue(value || {});
|
||||||
setSelectingType("from");
|
setSelectingType("from");
|
||||||
|
setViewMode("calendar");
|
||||||
}
|
}
|
||||||
}, [isOpen, value]);
|
}, [isOpen, value]);
|
||||||
|
|
||||||
|
|
@ -234,60 +237,150 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 월 네비게이션 */}
|
{viewMode === "year" ? (
|
||||||
<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" />
|
<div className="mb-4 flex items-center justify-between">
|
||||||
</Button>
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
|
||||||
<div className="text-sm font-medium">{format(currentMonth, "yyyy년 MM월", { locale: ko })}</div>
|
<ChevronLeft className="h-4 w-4" />
|
||||||
<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")}
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
<div className="text-sm font-medium">
|
||||||
})}
|
{yearRangeStart} - {yearRangeStart + 11}
|
||||||
</div>
|
</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) && (
|
{(tempValue.from || tempValue.to) && (
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ export const GroupingPanel: React.FC<Props> = ({
|
||||||
전체 해제
|
전체 해제
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="max-h-[40vh] space-y-2 overflow-y-auto pr-1">
|
||||||
{selectedColumns.map((colName, index) => {
|
{selectedColumns.map((colName, index) => {
|
||||||
const col = table?.columns.find(
|
const col = table?.columns.find(
|
||||||
(c) => c.columnName === colName
|
(c) => c.columnName === colName
|
||||||
|
|
|
||||||
|
|
@ -557,7 +557,7 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
|
||||||
전체 해제
|
전체 해제
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="max-h-[40vh] space-y-2 overflow-y-auto pr-1">
|
||||||
{selectedGroupColumns.map((colName, index) => {
|
{selectedGroupColumns.map((colName, index) => {
|
||||||
const col = table?.columns.find((c) => c.columnName === colName);
|
const col = table?.columns.find((c) => c.columnName === colName);
|
||||||
if (!col) return null;
|
if (!col) return null;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,22 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { WebTypeComponentProps } from "@/lib/registry/types";
|
||||||
import { WidgetComponent, DateTypeConfig } from "@/types/screen";
|
import { WidgetComponent, DateTypeConfig } from "@/types/screen";
|
||||||
|
|
||||||
|
|
@ -10,99 +25,341 @@ export const DateWidget: React.FC<WebTypeComponentProps> = ({ component, value,
|
||||||
const { placeholder, required, style } = widget;
|
const { placeholder, required, style } = widget;
|
||||||
const config = widget.webTypeConfig as DateTypeConfig | undefined;
|
const config = widget.webTypeConfig as DateTypeConfig | undefined;
|
||||||
|
|
||||||
// 사용자가 테두리를 설정했는지 확인
|
|
||||||
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
||||||
const borderClass = hasCustomBorder ? "!border-0" : "";
|
const borderClass = hasCustomBorder ? "!border-0" : "";
|
||||||
|
|
||||||
// 날짜 포맷팅 함수
|
const isDatetime = widget.widgetType === "datetime";
|
||||||
const formatDateValue = (val: string) => {
|
|
||||||
if (!val) return "";
|
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
const date = new Date(val);
|
const date = new Date(val);
|
||||||
if (isNaN(date.getTime())) return val;
|
if (isNaN(date.getTime())) return undefined;
|
||||||
|
return date;
|
||||||
if (widget.widgetType === "datetime") {
|
|
||||||
return date.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm
|
|
||||||
} else {
|
|
||||||
return date.toISOString().slice(0, 10); // YYYY-MM-DD
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
return val;
|
return undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 날짜 유효성 검증
|
const getDefaultValue = (): string => {
|
||||||
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 = () => {
|
|
||||||
if (config?.defaultValue === "current") {
|
if (config?.defaultValue === "current") {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (widget.widgetType === "datetime") {
|
if (isDatetime) return now.toISOString().slice(0, 16);
|
||||||
return now.toISOString().slice(0, 16);
|
return now.toISOString().slice(0, 10);
|
||||||
} else {
|
|
||||||
return now.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const finalValue = value || getDefaultValue();
|
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 (
|
return (
|
||||||
<Input
|
<Popover open={isOpen} onOpenChange={(v) => { if (!v) { setIsOpen(false); setIsTyping(false); } }}>
|
||||||
type={getInputType()}
|
<PopoverTrigger asChild>
|
||||||
value={formatDateValue(finalValue)}
|
<div
|
||||||
placeholder={placeholder || config?.placeholder || "날짜를 선택하세요..."}
|
className={cn(
|
||||||
onChange={handleChange}
|
"border-input bg-background flex h-full w-full cursor-pointer items-center rounded-md border px-3",
|
||||||
disabled={readonly}
|
readonly && "cursor-not-allowed opacity-50",
|
||||||
required={required}
|
!selectedDate && !isTyping && "text-muted-foreground",
|
||||||
className={`h-full w-full ${borderClass}`}
|
borderClass,
|
||||||
min={config?.minDate}
|
)}
|
||||||
max={config?.maxDate}
|
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";
|
DateWidget.displayName = "DateWidget";
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import React, { useState, useEffect, useMemo } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
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";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
interface CategoryColumn {
|
interface CategoryColumn {
|
||||||
|
|
@ -30,6 +30,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
||||||
const [columns, setColumns] = useState<CategoryColumn[]>([]);
|
const [columns, setColumns] = useState<CategoryColumn[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// 검색어로 필터링된 컬럼 목록
|
// 검색어로 필터링된 컬럼 목록
|
||||||
const filteredColumns = useMemo(() => {
|
const filteredColumns = useMemo(() => {
|
||||||
|
|
@ -49,6 +50,44 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
||||||
});
|
});
|
||||||
}, [columns, searchQuery]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
// 메뉴 종속 없이 항상 회사 기준으로 카테고리 컬럼 조회
|
// 메뉴 종속 없이 항상 회사 기준으로 카테고리 컬럼 조회
|
||||||
loadCategoryColumnsByMenu();
|
loadCategoryColumnsByMenu();
|
||||||
|
|
@ -279,35 +318,114 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
{filteredColumns.length === 0 && searchQuery ? (
|
{filteredColumns.length === 0 && searchQuery ? (
|
||||||
<div className="text-muted-foreground py-4 text-center text-xs">
|
<div className="text-muted-foreground py-4 text-center text-xs">
|
||||||
'{searchQuery}'에 대한 검색 결과가 없습니다
|
'{searchQuery}'에 대한 검색 결과가 없습니다
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{filteredColumns.map((column) => {
|
{groupedColumns.map((group) => {
|
||||||
const uniqueKey = `${column.tableName}.${column.columnName}`;
|
const isExpanded = expandedGroups.has(group.tableName);
|
||||||
const isSelected = selectedColumn === uniqueKey; // 테이블명.컬럼명으로 비교
|
const totalValues = group.columns.reduce((sum, c) => sum + (c.valueCount ?? 0), 0);
|
||||||
return (
|
const hasSelectedInGroup = group.columns.some(
|
||||||
<div
|
(c) => selectedColumn === `${c.tableName}.${c.columnName}`,
|
||||||
key={uniqueKey}
|
);
|
||||||
onClick={() => onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)}
|
|
||||||
className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${
|
// 그룹이 1개뿐이면 드롭다운 없이 바로 표시
|
||||||
isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
|
if (groupedColumns.length <= 1) {
|
||||||
}`}
|
return (
|
||||||
>
|
<div key={group.tableName} className="space-y-1.5">
|
||||||
<div className="flex items-center gap-2">
|
{group.columns.map((column) => {
|
||||||
<FolderTree
|
const uniqueKey = `${column.tableName}.${column.columnName}`;
|
||||||
className={`h-4 w-4 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
|
const isSelected = selectedColumn === uniqueKey;
|
||||||
/>
|
return (
|
||||||
<div className="flex-1">
|
<div
|
||||||
<h4 className="text-sm font-semibold">{column.columnLabel || column.columnName}</h4>
|
key={uniqueKey}
|
||||||
<p className="text-muted-foreground text-xs">{column.tableLabel || column.tableName}</p>
|
onClick={() => onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)}
|
||||||
</div>
|
className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${
|
||||||
<span className="text-muted-foreground text-xs font-medium">
|
isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
|
||||||
{column.valueCount !== undefined ? `${column.valueCount}개` : "..."}
|
}`}
|
||||||
</span>
|
>
|
||||||
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-[999] bg-black/80",
|
"fixed inset-0 z-[1050] bg-black/80",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
|
||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,13 @@
|
||||||
* - range 옵션: 범위 선택 (시작~종료)
|
* - range 옵션: 범위 선택 (시작~종료)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { forwardRef, useCallback, useMemo, useState } from "react";
|
import React, { forwardRef, useCallback, useMemo, useState, useEffect } from "react";
|
||||||
import { format, parse, isValid } from "date-fns";
|
import { format, parse, isValid, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isSameDay, isToday as isTodayFn } from "date-fns";
|
||||||
import { ko } from "date-fns/locale";
|
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 { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { V2DateProps, V2DateType } from "@/types/v2-components";
|
import { V2DateProps, V2DateType } from "@/types/v2-components";
|
||||||
|
|
@ -60,11 +59,24 @@ function formatDate(date: Date | undefined, formatStr: string): string {
|
||||||
return format(date, dateFnsFormat);
|
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<
|
const SingleDatePicker = forwardRef<
|
||||||
HTMLButtonElement,
|
HTMLDivElement,
|
||||||
{
|
{
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
|
|
@ -83,80 +95,227 @@ const SingleDatePicker = forwardRef<
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const [open, setOpen] = useState(false);
|
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 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(() => {
|
const displayText = useMemo(() => {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
// Date 객체로 변환 후 포맷팅
|
if (date && isValid(date)) return formatDate(date, dateFormat);
|
||||||
if (date && isValid(date)) {
|
|
||||||
return formatDate(date, dateFormat);
|
|
||||||
}
|
|
||||||
return value;
|
return value;
|
||||||
}, [value, date, dateFormat]);
|
}, [value, date, dateFormat]);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
useEffect(() => {
|
||||||
(selectedDate: Date | undefined) => {
|
if (open) {
|
||||||
if (selectedDate) {
|
setViewMode("calendar");
|
||||||
onChange?.(formatDate(selectedDate, dateFormat));
|
if (date && isValid(date)) {
|
||||||
setOpen(false);
|
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);
|
||||||
}
|
}
|
||||||
},
|
} else {
|
||||||
[dateFormat, onChange],
|
setIsTyping(false);
|
||||||
);
|
setTypingValue("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleDateClick = useCallback((clickedDate: Date) => {
|
||||||
|
onChange?.(formatDate(clickedDate, dateFormat));
|
||||||
|
setIsTyping(false);
|
||||||
|
setOpen(false);
|
||||||
|
}, [dateFormat, onChange]);
|
||||||
|
|
||||||
const handleToday = useCallback(() => {
|
const handleToday = useCallback(() => {
|
||||||
onChange?.(formatDate(new Date(), dateFormat));
|
onChange?.(formatDate(new Date(), dateFormat));
|
||||||
|
setIsTyping(false);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [dateFormat, onChange]);
|
}, [dateFormat, onChange]);
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
onChange?.("");
|
onChange?.("");
|
||||||
|
setIsTyping(false);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [onChange]);
|
}, [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 (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={(v) => { if (!v) { setOpen(false); setIsTyping(false); } }}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
variant="outline"
|
|
||||||
disabled={disabled || readonly}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full w-full justify-start text-left font-normal",
|
"border-input bg-background flex h-full w-full cursor-pointer items-center rounded-md border px-3",
|
||||||
!displayText && "text-muted-foreground",
|
(disabled || readonly) && "cursor-not-allowed opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
onClick={() => { if (!disabled && !readonly) setOpen(true); }}
|
||||||
>
|
>
|
||||||
<CalendarIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
|
||||||
{displayText || placeholder}
|
<input
|
||||||
</Button>
|
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>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||||
<Calendar
|
<div className="p-4">
|
||||||
mode="single"
|
<div className="mb-3 flex items-center gap-2">
|
||||||
selected={date}
|
{showToday && (
|
||||||
onSelect={handleSelect}
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleToday}>
|
||||||
initialFocus
|
오늘
|
||||||
locale={ko}
|
</Button>
|
||||||
disabled={(date) => {
|
)}
|
||||||
if (minDateObj && date < minDateObj) return true;
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleClear}>
|
||||||
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}>
|
|
||||||
오늘
|
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</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<
|
const RangeDatePicker = forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
{
|
{
|
||||||
|
|
@ -186,102 +488,38 @@ const RangeDatePicker = forwardRef<
|
||||||
|
|
||||||
const startDate = useMemo(() => parseDate(value[0], dateFormat), [value, dateFormat]);
|
const startDate = useMemo(() => parseDate(value[0], dateFormat), [value, dateFormat]);
|
||||||
const endDate = useMemo(() => parseDate(value[1], 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(
|
const handleStartSelect = useCallback(
|
||||||
(date: Date | undefined) => {
|
(date: Date) => {
|
||||||
if (date) {
|
const newStart = formatDate(date, dateFormat);
|
||||||
const newStart = formatDate(date, dateFormat);
|
if (endDate && date > endDate) {
|
||||||
// 시작일이 종료일보다 크면 종료일도 같이 변경
|
onChange?.([newStart, newStart]);
|
||||||
if (endDate && date > endDate) {
|
} else {
|
||||||
onChange?.([newStart, newStart]);
|
onChange?.([newStart, value[1]]);
|
||||||
} else {
|
|
||||||
onChange?.([newStart, value[1]]);
|
|
||||||
}
|
|
||||||
setOpenStart(false);
|
|
||||||
}
|
}
|
||||||
|
setOpenStart(false);
|
||||||
},
|
},
|
||||||
[value, dateFormat, endDate, onChange],
|
[value, dateFormat, endDate, onChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleEndSelect = useCallback(
|
const handleEndSelect = useCallback(
|
||||||
(date: Date | undefined) => {
|
(date: Date) => {
|
||||||
if (date) {
|
const newEnd = formatDate(date, dateFormat);
|
||||||
const newEnd = formatDate(date, dateFormat);
|
if (startDate && date < startDate) {
|
||||||
// 종료일이 시작일보다 작으면 시작일도 같이 변경
|
onChange?.([newEnd, newEnd]);
|
||||||
if (startDate && date < startDate) {
|
} else {
|
||||||
onChange?.([newEnd, newEnd]);
|
onChange?.([value[0], newEnd]);
|
||||||
} else {
|
|
||||||
onChange?.([value[0], newEnd]);
|
|
||||||
}
|
|
||||||
setOpenEnd(false);
|
|
||||||
}
|
}
|
||||||
|
setOpenEnd(false);
|
||||||
},
|
},
|
||||||
[value, dateFormat, startDate, onChange],
|
[value, dateFormat, startDate, onChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={cn("flex items-center gap-2 h-full", className)}>
|
<div ref={ref} className={cn("flex items-center gap-2 h-full", className)}>
|
||||||
{/* 시작 날짜 */}
|
<RangeCalendarPopover open={openStart} onOpenChange={setOpenStart} selectedDate={startDate} onSelect={handleStartSelect} label="시작일" disabled={disabled} readonly={readonly} displayValue={value[0]} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<span className="text-muted-foreground">~</span>
|
<span className="text-muted-foreground">~</span>
|
||||||
|
<RangeCalendarPopover open={openEnd} onOpenChange={setOpenEnd} selectedDate={endDate} onSelect={handleEndSelect} label="종료일" disabled={disabled} readonly={readonly} displayValue={value[1]} />
|
||||||
{/* 종료 날짜 */}
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1328,9 +1328,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
|
{/* 확인 다이얼로그 */}
|
||||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||||
<AlertDialogContent className="z-[99999]">
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
|
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>{getConfirmMessage()}</AlertDialogDescription>
|
<AlertDialogDescription>{getConfirmMessage()}</AlertDialogDescription>
|
||||||
|
|
|
||||||
|
|
@ -24,29 +24,33 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
||||||
style,
|
style,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
// 컴포넌트 설정
|
|
||||||
const componentConfig = {
|
const componentConfig = {
|
||||||
...config,
|
...config,
|
||||||
...component.config,
|
...component.config,
|
||||||
} as ImageDisplayConfig;
|
} 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 = {
|
const componentStyle: React.CSSProperties = {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
|
||||||
if (isDesignMode) {
|
if (isDesignMode) {
|
||||||
componentStyle.border = "1px dashed #cbd5e1";
|
componentStyle.border = "1px dashed #cbd5e1";
|
||||||
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이벤트 핸들러
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClick?.();
|
onClick?.();
|
||||||
|
|
@ -88,7 +92,9 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{component.label}
|
{component.label}
|
||||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
{(component.required || componentConfig.required) && (
|
||||||
|
<span style={{ color: "#ef4444" }}>*</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -96,43 +102,53 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
border: "1px solid #d1d5db",
|
border: showBorder ? "1px solid #d1d5db" : "none",
|
||||||
borderRadius: "8px",
|
borderRadius: `${borderRadius}px`,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
backgroundColor: "#f9fafb",
|
backgroundColor,
|
||||||
transition: "all 0.2s ease-in-out",
|
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) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.borderColor = "#f97316";
|
if (!componentConfig.disabled) {
|
||||||
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)";
|
if (showBorder) {
|
||||||
|
e.currentTarget.style.borderColor = "#f97316";
|
||||||
|
}
|
||||||
|
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)";
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.borderColor = "#d1d5db";
|
if (showBorder) {
|
||||||
e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
e.currentTarget.style.borderColor = "#d1d5db";
|
||||||
|
}
|
||||||
|
e.currentTarget.style.boxShadow = showBorder
|
||||||
|
? "0 1px 2px 0 rgba(0, 0, 0, 0.05)"
|
||||||
|
: "none";
|
||||||
}}
|
}}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
>
|
>
|
||||||
{component.value || componentConfig.imageUrl ? (
|
{imageSrc ? (
|
||||||
<img
|
<img
|
||||||
src={component.value || componentConfig.imageUrl}
|
src={imageSrc}
|
||||||
alt={componentConfig.altText || "이미지"}
|
alt={altText}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
maxHeight: "100%",
|
maxHeight: "100%",
|
||||||
objectFit: componentConfig.objectFit || "contain",
|
objectFit,
|
||||||
}}
|
}}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
(e.target as HTMLImageElement).style.display = "none";
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
if (e.target?.parentElement) {
|
if (e.target?.parentElement) {
|
||||||
e.target.parentElement.innerHTML = `
|
e.target.parentElement.innerHTML = `
|
||||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px; color: #6b7280; font-size: 14px;">
|
<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>이미지 로드 실패</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -150,8 +166,22 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
||||||
fontSize: "14px",
|
fontSize: "14px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: "32px" }}>🖼️</div>
|
<svg
|
||||||
<div>이미지 없음</div>
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -161,7 +191,6 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ImageDisplay 래퍼 컴포넌트
|
* ImageDisplay 래퍼 컴포넌트
|
||||||
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
|
||||||
*/
|
*/
|
||||||
export const ImageDisplayWrapper: React.FC<ImageDisplayComponentProps> = (props) => {
|
export const ImageDisplayWrapper: React.FC<ImageDisplayComponentProps> = (props) => {
|
||||||
return <ImageDisplayComponent {...props} />;
|
return <ImageDisplayComponent {...props} />;
|
||||||
|
|
|
||||||
|
|
@ -9,63 +9,166 @@ import { ImageDisplayConfig } from "./types";
|
||||||
|
|
||||||
export interface ImageDisplayConfigPanelProps {
|
export interface ImageDisplayConfigPanelProps {
|
||||||
config: ImageDisplayConfig;
|
config: ImageDisplayConfig;
|
||||||
onChange: (config: Partial<ImageDisplayConfig>) => void;
|
onChange?: (config: Partial<ImageDisplayConfig>) => void;
|
||||||
|
onConfigChange?: (config: Partial<ImageDisplayConfig>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ImageDisplay 설정 패널
|
* ImageDisplay 설정 패널
|
||||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
|
||||||
*/
|
*/
|
||||||
export const ImageDisplayConfigPanel: React.FC<ImageDisplayConfigPanelProps> = ({
|
export const ImageDisplayConfigPanel: React.FC<ImageDisplayConfigPanelProps> = ({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
|
onConfigChange,
|
||||||
}) => {
|
}) => {
|
||||||
const handleChange = (key: keyof ImageDisplayConfig, value: any) => {
|
const handleChange = (key: keyof ImageDisplayConfig, value: any) => {
|
||||||
onChange({ [key]: value });
|
const update = { ...config, [key]: value };
|
||||||
|
onChange?.(update);
|
||||||
|
onConfigChange?.(update);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-sm font-medium">
|
<div className="text-sm font-medium">이미지 표시 설정</div>
|
||||||
image-display 설정
|
|
||||||
|
{/* 이미지 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>
|
</div>
|
||||||
|
|
||||||
{/* file 관련 설정 */}
|
{/* 대체 텍스트 */}
|
||||||
<div className="space-y-2">
|
<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
|
<Input
|
||||||
id="placeholder"
|
id="placeholder"
|
||||||
value={config.placeholder || ""}
|
value={config.placeholder || ""}
|
||||||
onChange={(e) => handleChange("placeholder", e.target.value)}
|
onChange={(e) => handleChange("placeholder", e.target.value)}
|
||||||
|
placeholder="이미지 없음"
|
||||||
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 공통 설정 */}
|
{/* 테두리 표시 */}
|
||||||
<div className="space-y-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label htmlFor="disabled">비활성화</Label>
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="disabled"
|
id="showBorder"
|
||||||
checked={config.disabled || false}
|
checked={config.showBorder ?? true}
|
||||||
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
onCheckedChange={(checked) => handleChange("showBorder", checked)}
|
||||||
/>
|
/>
|
||||||
|
<Label htmlFor="showBorder" className="text-xs cursor-pointer">
|
||||||
|
테두리 표시
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* 읽기 전용 */}
|
||||||
<Label htmlFor="required">필수 입력</Label>
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
|
||||||
id="required"
|
|
||||||
checked={config.required || false}
|
|
||||||
onCheckedChange={(checked) => handleChange("required", checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="readonly">읽기 전용</Label>
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="readonly"
|
id="readonly"
|
||||||
checked={config.readonly || false}
|
checked={config.readonly || false}
|
||||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,14 @@ import { ImageDisplayConfig } from "./types";
|
||||||
* ImageDisplay 컴포넌트 기본 설정
|
* ImageDisplay 컴포넌트 기본 설정
|
||||||
*/
|
*/
|
||||||
export const ImageDisplayDefaultConfig: ImageDisplayConfig = {
|
export const ImageDisplayDefaultConfig: ImageDisplayConfig = {
|
||||||
placeholder: "입력하세요",
|
imageUrl: "",
|
||||||
|
altText: "이미지",
|
||||||
// 공통 기본값
|
objectFit: "contain",
|
||||||
|
borderRadius: 8,
|
||||||
|
showBorder: true,
|
||||||
|
backgroundColor: "#f9fafb",
|
||||||
|
placeholder: "이미지 없음",
|
||||||
|
|
||||||
disabled: false,
|
disabled: false,
|
||||||
required: false,
|
required: false,
|
||||||
readonly: false,
|
readonly: false,
|
||||||
|
|
@ -18,23 +23,31 @@ export const ImageDisplayDefaultConfig: ImageDisplayConfig = {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ImageDisplay 컴포넌트 설정 스키마
|
* ImageDisplay 컴포넌트 설정 스키마
|
||||||
* 유효성 검사 및 타입 체크에 사용
|
|
||||||
*/
|
*/
|
||||||
export const ImageDisplayConfigSchema = {
|
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 },
|
disabled: { type: "boolean", default: false },
|
||||||
required: { type: "boolean", default: false },
|
required: { type: "boolean", default: false },
|
||||||
readonly: { type: "boolean", default: false },
|
readonly: { type: "boolean", default: false },
|
||||||
variant: {
|
variant: {
|
||||||
type: "enum",
|
type: "enum",
|
||||||
values: ["default", "outlined", "filled"],
|
values: ["default", "outlined", "filled"],
|
||||||
default: "default"
|
default: "default",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
type: "enum",
|
type: "enum",
|
||||||
values: ["sm", "md", "lg"],
|
values: ["sm", "md", "lg"],
|
||||||
default: "md"
|
default: "md",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,13 @@ export const ImageDisplayDefinition = createComponentDefinition({
|
||||||
webType: "file",
|
webType: "file",
|
||||||
component: ImageDisplayWrapper,
|
component: ImageDisplayWrapper,
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
placeholder: "입력하세요",
|
imageUrl: "",
|
||||||
|
altText: "이미지",
|
||||||
|
objectFit: "contain",
|
||||||
|
borderRadius: 8,
|
||||||
|
showBorder: true,
|
||||||
|
backgroundColor: "#f9fafb",
|
||||||
|
placeholder: "이미지 없음",
|
||||||
},
|
},
|
||||||
defaultSize: { width: 200, height: 200 },
|
defaultSize: { width: 200, height: 200 },
|
||||||
configPanel: ImageDisplayConfigPanel,
|
configPanel: ImageDisplayConfigPanel,
|
||||||
|
|
|
||||||
|
|
@ -6,20 +6,24 @@ import { ComponentConfig } from "@/types/component";
|
||||||
* ImageDisplay 컴포넌트 설정 타입
|
* ImageDisplay 컴포넌트 설정 타입
|
||||||
*/
|
*/
|
||||||
export interface ImageDisplayConfig extends ComponentConfig {
|
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;
|
placeholder?: string;
|
||||||
|
|
||||||
// 공통 설정
|
// 공통 설정
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
placeholder?: string;
|
|
||||||
helperText?: string;
|
|
||||||
|
|
||||||
// 스타일 관련
|
// 스타일 관련
|
||||||
variant?: "default" | "outlined" | "filled";
|
variant?: "default" | "outlined" | "filled";
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
|
|
||||||
// 이벤트 관련
|
// 이벤트 관련
|
||||||
onChange?: (value: any) => void;
|
onChange?: (value: any) => void;
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
|
|
@ -37,7 +41,7 @@ export interface ImageDisplayProps {
|
||||||
config?: ImageDisplayConfig;
|
config?: ImageDisplayConfig;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
|
||||||
// 이벤트 핸들러
|
// 이벤트 핸들러
|
||||||
onChange?: (value: any) => void;
|
onChange?: (value: any) => void;
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import { apiClient } from "@/lib/api/client";
|
||||||
import { allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule";
|
import { allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule";
|
||||||
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||||
import { CascadingDropdownConfig } from "@/types/screen-management";
|
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||||
|
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
UniversalFormModalComponentProps,
|
UniversalFormModalComponentProps,
|
||||||
|
|
@ -1835,11 +1836,11 @@ export function UniversalFormModalComponent({
|
||||||
|
|
||||||
case "date":
|
case "date":
|
||||||
return (
|
return (
|
||||||
<Input
|
<FormDatePicker
|
||||||
id={fieldKey}
|
id={fieldKey}
|
||||||
type="date"
|
|
||||||
value={value || ""}
|
value={value || ""}
|
||||||
onChange={(e) => onChangeHandler(e.target.value)}
|
onChange={onChangeHandler}
|
||||||
|
placeholder={field.placeholder || "날짜를 선택하세요"}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
readOnly={field.readOnly}
|
readOnly={field.readOnly}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1847,13 +1848,14 @@ export function UniversalFormModalComponent({
|
||||||
|
|
||||||
case "datetime":
|
case "datetime":
|
||||||
return (
|
return (
|
||||||
<Input
|
<FormDatePicker
|
||||||
id={fieldKey}
|
id={fieldKey}
|
||||||
type="datetime-local"
|
|
||||||
value={value || ""}
|
value={value || ""}
|
||||||
onChange={(e) => onChangeHandler(e.target.value)}
|
onChange={onChangeHandler}
|
||||||
|
placeholder={field.placeholder || "날짜/시간을 선택하세요"}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
readOnly={field.readOnly}
|
readOnly={field.readOnly}
|
||||||
|
includeTime
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1481,9 +1481,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
|
{/* 확인 다이얼로그 */}
|
||||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||||
<AlertDialogContent className="z-[99999]">
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
|
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>{getConfirmMessage()}</AlertDialogDescription>
|
<AlertDialogDescription>{getConfirmMessage()}</AlertDialogDescription>
|
||||||
|
|
|
||||||
|
|
@ -247,14 +247,12 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex h-[75vh] flex-col space-y-3">
|
<div className="flex h-[75vh] flex-col space-y-3">
|
||||||
{/* 파일 업로드 영역 - 높이 축소 */}
|
{/* 파일 업로드 영역 - readonly/disabled이면 숨김 */}
|
||||||
{!isDesignMode && (
|
{!isDesignMode && !config.readonly && !config.disabled && (
|
||||||
<div
|
<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={() => {
|
onClick={() => {
|
||||||
if (!config.disabled && !isDesignMode) {
|
fileInputRef.current?.click();
|
||||||
fileInputRef.current?.click();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
|
|
@ -267,7 +265,6 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
accept={config.accept}
|
accept={config.accept}
|
||||||
onChange={handleFileInputChange}
|
onChange={handleFileInputChange}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
disabled={config.disabled}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{uploading ? (
|
{uploading ? (
|
||||||
|
|
@ -286,8 +283,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
|
|
||||||
{/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
|
{/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
|
||||||
<div className="flex min-h-0 flex-1 gap-4">
|
<div className="flex min-h-0 flex-1 gap-4">
|
||||||
{/* 좌측: 이미지 미리보기 (확대/축소 가능) */}
|
{/* 좌측: 이미지 미리보기 (확대/축소 가능) - showPreview가 false면 숨김 */}
|
||||||
<div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border border-gray-200 bg-gray-900">
|
{(config.showPreview !== false) && <div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border border-gray-200 bg-gray-900">
|
||||||
{/* 확대/축소 컨트롤 */}
|
{/* 확대/축소 컨트롤 */}
|
||||||
{selectedFile && previewImageUrl && (
|
{selectedFile && previewImageUrl && (
|
||||||
<div className="absolute top-3 left-3 z-10 flex items-center gap-1 rounded-lg bg-black/60 p-1">
|
<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}
|
{selectedFile.realFileName}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* 우측: 파일 목록 (고정 너비) */}
|
{/* 우측: 파일 목록 - showFileList가 false면 숨김, showPreview가 false면 전체 너비 */}
|
||||||
<div className="flex w-[400px] shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200">
|
{(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="border-b border-gray-200 bg-gray-50 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium text-gray-700">업로드된 파일</h3>
|
<h3 className="text-sm font-medium text-gray-700">업로드된 파일</h3>
|
||||||
|
|
@ -404,7 +401,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{formatFileSize(file.fileSize)} • {file.fileExt.toUpperCase()}
|
{config.showFileSize !== false && <>{formatFileSize(file.fileSize)} • </>}{file.fileExt.toUpperCase()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
|
|
@ -434,19 +431,21 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
>
|
>
|
||||||
<Eye className="h-3 w-3" />
|
<Eye className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{config.allowDownload !== false && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
className="h-7 w-7 p-0"
|
size="sm"
|
||||||
onClick={(e) => {
|
className="h-7 w-7 p-0"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
onFileDownload(file);
|
e.stopPropagation();
|
||||||
}}
|
onFileDownload(file);
|
||||||
title="다운로드"
|
}}
|
||||||
>
|
title="다운로드"
|
||||||
<Download className="h-3 w-3" />
|
>
|
||||||
</Button>
|
<Download className="h-3 w-3" />
|
||||||
{!isDesignMode && (
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isDesignMode && config.allowDelete !== false && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -476,7 +475,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
@ -487,8 +486,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
file={viewerFile}
|
file={viewerFile}
|
||||||
isOpen={isViewerOpen}
|
isOpen={isViewerOpen}
|
||||||
onClose={handleViewerClose}
|
onClose={handleViewerClose}
|
||||||
onDownload={onFileDownload}
|
onDownload={config.allowDownload !== false ? onFileDownload : undefined}
|
||||||
onDelete={!isDesignMode ? onFileDelete : undefined}
|
onDelete={!isDesignMode && config.allowDelete !== false ? onFileDelete : undefined}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
const [forceUpdate, setForceUpdate] = useState(0);
|
const [forceUpdate, setForceUpdate] = useState(0);
|
||||||
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
|
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
// objid 기반으로 파일이 로드되었는지 추적 (다른 이펙트가 덮어쓰지 않도록 방지)
|
||||||
|
const filesLoadedFromObjidRef = useRef(false);
|
||||||
|
|
||||||
// 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리
|
// 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리
|
||||||
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
|
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
|
||||||
|
|
@ -150,6 +152,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
if (isRecordMode || !recordId) {
|
if (isRecordMode || !recordId) {
|
||||||
setUploadedFiles([]);
|
setUploadedFiles([]);
|
||||||
setRepresentativeImageUrl(null);
|
setRepresentativeImageUrl(null);
|
||||||
|
filesLoadedFromObjidRef.current = false;
|
||||||
}
|
}
|
||||||
} else if (prevIsRecordModeRef.current === null) {
|
} else if (prevIsRecordModeRef.current === null) {
|
||||||
// 초기 마운트 시 모드 저장
|
// 초기 마운트 시 모드 저장
|
||||||
|
|
@ -191,63 +194,68 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
}, [component.id, getUniqueKey, recordId, isRecordMode]); // 레코드별 고유 키 변경 시 재실행
|
}, [component.id, getUniqueKey, recordId, isRecordMode]); // 레코드별 고유 키 변경 시 재실행
|
||||||
|
|
||||||
// 🔑 수정 모드: formData[columnName]에 저장된 objid로 이미지 로드
|
// 🔑 수정 모드: formData[columnName]에 저장된 objid로 이미지 로드
|
||||||
// 🆕 formData 전체가 아닌 특정 컬럼 값만 의존하도록 수정 (다른 컴포넌트 영향 방지)
|
// 콤마로 구분된 다중 objid도 처리 (예: "123,456")
|
||||||
const imageObjidFromFormData = formData?.[columnName];
|
const imageObjidFromFormData = formData?.[columnName];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 이미지 objid가 있고, 숫자 문자열인 경우에만 처리
|
if (!imageObjidFromFormData) return;
|
||||||
if (imageObjidFromFormData && /^\d+$/.test(String(imageObjidFromFormData))) {
|
|
||||||
const objidStr = String(imageObjidFromFormData);
|
const rawValue = String(imageObjidFromFormData);
|
||||||
|
// 콤마 구분 다중 objid 또는 단일 objid 모두 처리
|
||||||
// 이미 같은 objid의 파일이 로드되어 있으면 스킵
|
const objids = rawValue.split(',').map(s => s.trim()).filter(s => /^\d+$/.test(s));
|
||||||
const alreadyLoaded = uploadedFiles.some(f => String(f.objid) === objidStr);
|
|
||||||
if (alreadyLoaded) {
|
if (objids.length === 0) return;
|
||||||
return;
|
|
||||||
}
|
// 모든 objid가 이미 로드되어 있으면 스킵
|
||||||
|
const allLoaded = objids.every(id => uploadedFiles.some(f => String(f.objid) === id));
|
||||||
// 🔑 실제 파일 정보 조회 (previewUrl 제거 - apiClient blob 다운로드 방식으로 통일)
|
if (allLoaded) return;
|
||||||
(async () => {
|
|
||||||
try {
|
(async () => {
|
||||||
const fileInfoResponse = await getFileInfoByObjid(objidStr);
|
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) {
|
if (fileInfoResponse.success && fileInfoResponse.data) {
|
||||||
const { realFileName, fileSize, fileExt, regdate, isRepresentative } = fileInfoResponse.data;
|
const { realFileName, fileSize, fileExt, regdate, isRepresentative } = fileInfoResponse.data;
|
||||||
|
|
||||||
const fileInfo = {
|
loadedFiles.push({
|
||||||
objid: objidStr,
|
objid,
|
||||||
realFileName: realFileName,
|
realFileName,
|
||||||
fileExt: fileExt,
|
fileExt,
|
||||||
fileSize: fileSize,
|
fileSize,
|
||||||
filePath: getFilePreviewUrl(objidStr),
|
filePath: getFilePreviewUrl(objid),
|
||||||
regdate: regdate,
|
regdate,
|
||||||
isImage: true,
|
isImage: true,
|
||||||
isRepresentative: isRepresentative,
|
isRepresentative,
|
||||||
};
|
} as FileInfo);
|
||||||
|
|
||||||
setUploadedFiles([fileInfo]);
|
|
||||||
// representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨
|
|
||||||
} else {
|
} else {
|
||||||
// 파일 정보 조회 실패 시 최소 정보로 추가
|
// 파일 정보 조회 실패 시 최소 정보로 추가
|
||||||
console.warn("🖼️ [FileUploadComponent] 파일 정보 조회 실패, 최소 정보 사용");
|
loadedFiles.push({
|
||||||
const minimalFileInfo = {
|
objid,
|
||||||
objid: objidStr,
|
realFileName: `file_${objid}`,
|
||||||
realFileName: `image_${objidStr}.jpg`,
|
|
||||||
fileExt: '.jpg',
|
fileExt: '.jpg',
|
||||||
fileSize: 0,
|
fileSize: 0,
|
||||||
filePath: getFilePreviewUrl(objidStr),
|
filePath: getFilePreviewUrl(objid),
|
||||||
regdate: new Date().toISOString(),
|
regdate: new Date().toISOString(),
|
||||||
isImage: true,
|
isImage: true,
|
||||||
};
|
} as FileInfo);
|
||||||
|
|
||||||
setUploadedFiles([minimalFileInfo]);
|
|
||||||
// representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error);
|
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
}
|
if (loadedFiles.length > 0) {
|
||||||
}, [imageObjidFromFormData, columnName, component.id]); // 🆕 formData 대신 특정 컬럼 값만 의존
|
setUploadedFiles(loadedFiles);
|
||||||
|
filesLoadedFromObjidRef.current = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [imageObjidFromFormData, columnName, component.id]);
|
||||||
|
|
||||||
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
|
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
|
||||||
// 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
|
// 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
|
||||||
|
|
@ -365,6 +373,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
...file,
|
...file,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 서버에서 0개 반환 + objid 기반 로딩이 이미 완료된 경우 덮어쓰지 않음
|
||||||
|
if (formattedFiles.length === 0 && filesLoadedFromObjidRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// 🔄 localStorage의 기존 파일과 서버 파일 병합 (레코드별 고유 키 사용)
|
// 🔄 localStorage의 기존 파일과 서버 파일 병합 (레코드별 고유 키 사용)
|
||||||
let finalFiles = formattedFiles;
|
let finalFiles = formattedFiles;
|
||||||
|
|
@ -427,14 +439,19 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
return; // DB 로드 성공 시 localStorage 무시
|
return; // DB 로드 성공 시 localStorage 무시
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 등록 모드(새 레코드)인 경우 fallback 로드도 스킵 - 항상 빈 상태 유지
|
// objid 기반으로 이미 파일이 로드된 경우 빈 데이터로 덮어쓰지 않음
|
||||||
|
if (filesLoadedFromObjidRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 등록 모드(새 레코드)인 경우 fallback 로드도 스킵 - 항상 빈 상태 유지
|
||||||
if (!isRecordMode || !recordId) {
|
if (!isRecordMode || !recordId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB 로드 실패 시에만 기존 로직 사용 (하위 호환성)
|
// DB 로드 실패 시에만 기존 로직 사용 (하위 호환성)
|
||||||
|
|
||||||
// 전역 상태에서 최신 파일 정보 가져오기 (🆕 고유 키 사용)
|
// 전역 상태에서 최신 파일 정보 가져오기 (고유 키 사용)
|
||||||
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
|
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
|
||||||
const uniqueKeyForFallback = getUniqueKey();
|
const uniqueKeyForFallback = getUniqueKey();
|
||||||
const globalFiles = globalFileState[uniqueKeyForFallback] || globalFileState[component.id] || [];
|
const globalFiles = globalFileState[uniqueKeyForFallback] || globalFileState[component.id] || [];
|
||||||
|
|
@ -442,6 +459,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
|
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
|
||||||
const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
|
const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
|
||||||
|
|
||||||
|
// 빈 데이터로 기존 파일을 덮어쓰지 않음
|
||||||
|
if (currentFiles.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 최신 파일과 현재 파일 비교
|
// 최신 파일과 현재 파일 비교
|
||||||
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
|
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
|
||||||
|
|
@ -1147,8 +1168,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
file={viewerFile}
|
file={viewerFile}
|
||||||
isOpen={isViewerOpen}
|
isOpen={isViewerOpen}
|
||||||
onClose={handleViewerClose}
|
onClose={handleViewerClose}
|
||||||
onDownload={handleFileDownload}
|
onDownload={safeComponentConfig.allowDownload !== false ? handleFileDownload : undefined}
|
||||||
onDelete={!isDesignMode ? handleFileDelete : 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) => {
|
const handleRowClick = (row: any, index: number, e: React.MouseEvent) => {
|
||||||
// 체크박스 클릭은 무시 (이미 handleRowSelection에서 처리됨)
|
// 체크박스 클릭은 무시 (이미 handleRowSelection에서 처리됨)
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.closest('input[type="checkbox"]')) {
|
if (target.closest('input[type="checkbox"]') || target.closest('button[role="checkbox"]')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2198,35 +2198,32 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택)
|
// 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택/해제 토글)
|
||||||
const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => {
|
const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setFocusedCell({ rowIndex, colIndex });
|
setFocusedCell({ rowIndex, colIndex });
|
||||||
// 테이블 컨테이너에 포커스 설정 (키보드 이벤트 수신용)
|
|
||||||
tableContainerRef.current?.focus();
|
tableContainerRef.current?.focus();
|
||||||
|
|
||||||
// 🆕 분할 패널 내에서 셀 클릭 시에도 해당 행 선택 처리
|
|
||||||
// filteredData에서 해당 행의 데이터 가져오기
|
|
||||||
const row = filteredData[rowIndex];
|
const row = filteredData[rowIndex];
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
|
|
||||||
|
// 체크박스 컬럼은 Checkbox의 onCheckedChange에서 이미 처리되므로 스킵
|
||||||
|
const column = visibleColumns[colIndex];
|
||||||
|
if (column?.columnName === "__checkbox__") return;
|
||||||
|
|
||||||
const rowKey = getRowKey(row, rowIndex);
|
const rowKey = getRowKey(row, rowIndex);
|
||||||
const isCurrentlySelected = selectedRows.has(rowKey);
|
const isCurrentlySelected = selectedRows.has(rowKey);
|
||||||
|
|
||||||
// 분할 패널 컨텍스트가 있고, 좌측 화면인 경우에만 행 선택 및 데이터 전달
|
|
||||||
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
|
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
|
||||||
|
|
||||||
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
||||||
// 이미 선택된 행과 다른 행을 클릭한 경우에만 처리
|
// 분할 패널 좌측: 단일 행 선택 모드
|
||||||
if (!isCurrentlySelected) {
|
if (!isCurrentlySelected) {
|
||||||
// 기존 선택 해제하고 새 행 선택
|
|
||||||
setSelectedRows(new Set([rowKey]));
|
setSelectedRows(new Set([rowKey]));
|
||||||
setIsAllSelected(false);
|
setIsAllSelected(false);
|
||||||
|
|
||||||
// 분할 패널 컨텍스트에 데이터 저장
|
|
||||||
splitPanelContext.setSelectedLeftData(row);
|
splitPanelContext.setSelectedLeftData(row);
|
||||||
|
|
||||||
// onSelectedRowsChange 콜백 호출
|
|
||||||
if (onSelectedRowsChange) {
|
if (onSelectedRowsChange) {
|
||||||
onSelectedRowsChange([rowKey], [row], sortColumn || undefined, sortDirection);
|
onSelectedRowsChange([rowKey], [row], sortColumn || undefined, sortDirection);
|
||||||
}
|
}
|
||||||
|
|
@ -2234,6 +2231,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
onFormDataChange({ selectedRows: [rowKey], selectedRowsData: [row] });
|
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 (
|
return (
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -437,7 +437,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [currentTable?.tableName, filterMode, screenId, currentTableTabId, JSON.stringify(presetFilters)]);
|
}, [currentTable?.tableName, filterMode, screenId, currentTableTabId, JSON.stringify(presetFilters)]);
|
||||||
|
|
||||||
// select 옵션 초기 로드 (한 번만 실행, 이후 유지)
|
// select 옵션 로드 (데이터 변경 시 빈 옵션 재조회)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentTable?.getColumnUniqueValues || activeFilters.length === 0) {
|
if (!currentTable?.getColumnUniqueValues || activeFilters.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -450,26 +450,37 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
return;
|
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) {
|
for (const filter of selectFilters) {
|
||||||
// 이미 로드된 옵션이 있으면 스킵 (초기값 유지)
|
|
||||||
if (newOptions[filter.columnName] && newOptions[filter.columnName].length > 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const options = await currentTable.getColumnUniqueValues(filter.columnName);
|
const options = await currentTable.getColumnUniqueValues(filter.columnName);
|
||||||
newOptions[filter.columnName] = options;
|
if (options && options.length > 0) {
|
||||||
|
loadedOptions[filter.columnName] = options;
|
||||||
|
hasNewOptions = true;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ [TableSearchWidget] select 옵션 로드 실패:", filter.columnName, 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();
|
loadSelectOptions();
|
||||||
}, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues]); // dataCount 제거, tableName으로 변경
|
}, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues, currentTable?.dataCount]);
|
||||||
|
|
||||||
// 높이 변화 감지 및 알림 (실제 화면에서만)
|
// 높이 변화 감지 및 알림 (실제 화면에서만)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -722,7 +733,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</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">
|
<div className="max-h-60 overflow-auto">
|
||||||
{uniqueOptions.length === 0 ? (
|
{uniqueOptions.length === 0 ? (
|
||||||
<div className="text-muted-foreground px-3 py-2 text-xs">옵션 없음</div>
|
<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)}
|
onCheckedChange={(checked) => handleMultiSelectChange(option.value, checked as boolean)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue