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:
kjs 2026-02-26 17:32:20 +09:00
parent 27be48464a
commit 17d4cc297c
22 changed files with 2001 additions and 461 deletions

View File

@ -1288,7 +1288,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
{/* 모달 닫기 확인 다이얼로그 */}
<AlertDialog open={showCloseConfirm} onOpenChange={setShowCloseConfirm}>
<AlertDialogContent className="!z-[1100] max-w-[95vw] sm:max-w-[400px]">
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg">
?

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -34,6 +34,8 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
const [isOpen, setIsOpen] = useState(false);
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectingType, setSelectingType] = useState<"from" | "to">("from");
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
// 로컬 임시 상태 (확인 버튼 누르기 전까지 임시 저장)
const [tempValue, setTempValue] = useState<DateRangeValue>(value || {});
@ -43,6 +45,7 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
if (isOpen) {
setTempValue(value || {});
setSelectingType("from");
setViewMode("calendar");
}
}, [isOpen, value]);
@ -234,60 +237,150 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
</Button>
</div>
{/* 월 네비게이션 */}
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-sm font-medium">{format(currentMonth, "yyyy년 MM월", { locale: ko })}</div>
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* 요일 헤더 */}
<div className="mb-2 grid grid-cols-7 gap-1">
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
{day}
</div>
))}
</div>
{/* 날짜 그리드 */}
<div className="mb-4 grid grid-cols-7 gap-1">
{allDays.map((date, index) => {
if (!date) {
return <div key={index} className="p-2" />;
}
const isCurrentMonth = isSameMonth(date, currentMonth);
const isSelected = isRangeStart(date) || isRangeEnd(date);
const isInRangeDate = isInRange(date);
const isTodayDate = isToday(date);
return (
<Button
key={date.toISOString()}
variant="ghost"
size="sm"
className={cn(
"h-8 w-8 p-0 text-xs",
!isCurrentMonth && "text-muted-foreground opacity-50",
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
isInRangeDate && !isSelected && "bg-muted",
isTodayDate && !isSelected && "border-primary border",
selectingType === "from" && "hover:bg-primary/20",
selectingType === "to" && "hover:bg-secondary/20",
)}
onClick={() => handleDateClick(date)}
disabled={!isCurrentMonth}
>
{format(date, "d")}
{viewMode === "year" ? (
<>
{/* 년도 선택 뷰 */}
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
<ChevronLeft className="h-4 w-4" />
</Button>
);
})}
</div>
<div className="text-sm font-medium">
{yearRangeStart} - {yearRangeStart + 11}
</div>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="mb-4 grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
<Button
key={year}
variant="ghost"
size="sm"
className={cn(
"h-9 text-xs",
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
)}
onClick={() => {
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
setViewMode("month");
}}
>
{year}
</Button>
))}
</div>
</>
) : viewMode === "month" ? (
<>
{/* 월 선택 뷰 */}
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<button
type="button"
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
onClick={() => {
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
setViewMode("year");
}}
>
{currentMonth.getFullYear()}
</button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="mb-4 grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
<Button
key={month}
variant="ghost"
size="sm"
className={cn(
"h-9 text-xs",
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
)}
onClick={() => {
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
setViewMode("calendar");
}}
>
{month + 1}
</Button>
))}
</div>
</>
) : (
<>
{/* 월 네비게이션 */}
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<button
type="button"
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
onClick={() => {
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
setViewMode("year");
}}
>
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
</button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* 요일 헤더 */}
<div className="mb-2 grid grid-cols-7 gap-1">
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
{day}
</div>
))}
</div>
{/* 날짜 그리드 */}
<div className="mb-4 grid grid-cols-7 gap-1">
{allDays.map((date, index) => {
if (!date) {
return <div key={index} className="p-2" />;
}
const isCurrentMonth = isSameMonth(date, currentMonth);
const isSelected = isRangeStart(date) || isRangeEnd(date);
const isInRangeDate = isInRange(date);
const isTodayDate = isToday(date);
return (
<Button
key={date.toISOString()}
variant="ghost"
size="sm"
className={cn(
"h-8 w-8 p-0 text-xs",
!isCurrentMonth && "text-muted-foreground opacity-50",
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
isInRangeDate && !isSelected && "bg-muted",
isTodayDate && !isSelected && "border-primary border",
selectingType === "from" && "hover:bg-primary/20",
selectingType === "to" && "hover:bg-secondary/20",
)}
onClick={() => handleDateClick(date)}
disabled={!isCurrentMonth}
>
{format(date, "d")}
</Button>
);
})}
</div>
</>
)}
{/* 선택된 범위 표시 */}
{(tempValue.from || tempValue.to) && (

View File

@ -99,7 +99,7 @@ export const GroupingPanel: React.FC<Props> = ({
</Button>
</div>
<div className="space-y-2">
<div className="max-h-[40vh] space-y-2 overflow-y-auto pr-1">
{selectedColumns.map((colName, index) => {
const col = table?.columns.find(
(c) => c.columnName === colName

View File

@ -557,7 +557,7 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
</Button>
</div>
<div className="space-y-2">
<div className="max-h-[40vh] space-y-2 overflow-y-auto pr-1">
{selectedGroupColumns.map((colName, index) => {
const col = table?.columns.find((c) => c.columnName === colName);
if (!col) return null;

View File

@ -1,7 +1,22 @@
"use client";
import React from "react";
import { Input } from "@/components/ui/input";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { CalendarIcon, ChevronLeft, ChevronRight, X } from "lucide-react";
import {
format,
addMonths,
subMonths,
startOfMonth,
endOfMonth,
eachDayOfInterval,
isSameMonth,
isSameDay,
isToday,
} from "date-fns";
import { ko } from "date-fns/locale";
import { cn } from "@/lib/utils";
import { WebTypeComponentProps } from "@/lib/registry/types";
import { WidgetComponent, DateTypeConfig } from "@/types/screen";
@ -10,99 +25,341 @@ export const DateWidget: React.FC<WebTypeComponentProps> = ({ component, value,
const { placeholder, required, style } = widget;
const config = widget.webTypeConfig as DateTypeConfig | undefined;
// 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
const borderClass = hasCustomBorder ? "!border-0" : "";
// 날짜 포맷팅 함수
const formatDateValue = (val: string) => {
if (!val) return "";
const isDatetime = widget.widgetType === "datetime";
const [isOpen, setIsOpen] = useState(false);
const [currentMonth, setCurrentMonth] = useState(new Date());
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
const [timeValue, setTimeValue] = useState("00:00");
const [isTyping, setIsTyping] = useState(false);
const [typingValue, setTypingValue] = useState("");
const parseDate = (val: string | undefined): Date | undefined => {
if (!val) return undefined;
try {
const date = new Date(val);
if (isNaN(date.getTime())) return val;
if (widget.widgetType === "datetime") {
return date.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm
} else {
return date.toISOString().slice(0, 10); // YYYY-MM-DD
}
if (isNaN(date.getTime())) return undefined;
return date;
} catch {
return val;
return undefined;
}
};
// 날짜 유효성 검증
const validateDate = (dateStr: string): boolean => {
if (!dateStr) return true;
const date = new Date(dateStr);
if (isNaN(date.getTime())) return false;
// 최소/최대 날짜 검증
if (config?.minDate) {
const minDate = new Date(config.minDate);
if (date < minDate) return false;
}
if (config?.maxDate) {
const maxDate = new Date(config.maxDate);
if (date > maxDate) return false;
}
return true;
};
// 입력값 처리
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
if (validateDate(inputValue)) {
onChange?.(inputValue);
}
};
// 웹타입에 따른 input type 결정
const getInputType = () => {
switch (widget.widgetType) {
case "datetime":
return "datetime-local";
case "date":
default:
return "date";
}
};
// 기본값 설정 (현재 날짜/시간)
const getDefaultValue = () => {
const getDefaultValue = (): string => {
if (config?.defaultValue === "current") {
const now = new Date();
if (widget.widgetType === "datetime") {
return now.toISOString().slice(0, 16);
} else {
return now.toISOString().slice(0, 10);
}
if (isDatetime) return now.toISOString().slice(0, 16);
return now.toISOString().slice(0, 10);
}
return "";
};
const finalValue = value || getDefaultValue();
const selectedDate = parseDate(finalValue);
useEffect(() => {
if (isOpen) {
setViewMode("calendar");
if (selectedDate) {
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
if (isDatetime) {
const hours = String(selectedDate.getHours()).padStart(2, "0");
const minutes = String(selectedDate.getMinutes()).padStart(2, "0");
setTimeValue(`${hours}:${minutes}`);
}
} else {
setCurrentMonth(new Date());
setTimeValue("00:00");
}
} else {
setIsTyping(false);
setTypingValue("");
}
}, [isOpen]);
const formatDisplayValue = (): string => {
if (!selectedDate) return "";
if (isDatetime) return format(selectedDate, "yyyy-MM-dd HH:mm", { locale: ko });
return format(selectedDate, "yyyy-MM-dd", { locale: ko });
};
const handleDateClick = (date: Date) => {
let dateStr: string;
if (isDatetime) {
const [hours, minutes] = timeValue.split(":").map(Number);
const dt = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hours || 0, minutes || 0);
dateStr = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")}T${timeValue}`;
} else {
dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
onChange?.(dateStr);
if (!isDatetime) {
setIsOpen(false);
}
};
const handleTimeChange = (newTime: string) => {
setTimeValue(newTime);
if (selectedDate) {
const dateStr = `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, "0")}-${String(selectedDate.getDate()).padStart(2, "0")}T${newTime}`;
onChange?.(dateStr);
}
};
const handleClear = () => {
onChange?.("");
setIsTyping(false);
setIsOpen(false);
};
const handleTriggerInput = (raw: string) => {
setIsTyping(true);
setTypingValue(raw);
if (!isOpen) setIsOpen(true);
const digitsOnly = raw.replace(/\D/g, "");
if (digitsOnly.length === 8) {
const y = parseInt(digitsOnly.slice(0, 4), 10);
const m = parseInt(digitsOnly.slice(4, 6), 10) - 1;
const d = parseInt(digitsOnly.slice(6, 8), 10);
const date = new Date(y, m, d);
if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) {
let dateStr: string;
if (isDatetime) {
dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}T${timeValue}`;
} else {
dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
}
onChange?.(dateStr);
setCurrentMonth(new Date(y, m, 1));
if (!isDatetime) setTimeout(() => { setIsTyping(false); setIsOpen(false); }, 400);
else setIsTyping(false);
}
}
};
const handleSetToday = () => {
const today = new Date();
if (isDatetime) {
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}T${String(today.getHours()).padStart(2, "0")}:${String(today.getMinutes()).padStart(2, "0")}`;
onChange?.(dateStr);
} else {
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
onChange?.(dateStr);
}
setIsOpen(false);
};
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
const startDate = new Date(monthStart);
const dayOfWeek = startDate.getDay();
const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const allDays = [...Array(paddingDays).fill(null), ...days];
return (
<Input
type={getInputType()}
value={formatDateValue(finalValue)}
placeholder={placeholder || config?.placeholder || "날짜를 선택하세요..."}
onChange={handleChange}
disabled={readonly}
required={required}
className={`h-full w-full ${borderClass}`}
min={config?.minDate}
max={config?.maxDate}
/>
<Popover open={isOpen} onOpenChange={(v) => { if (!v) { setIsOpen(false); setIsTyping(false); } }}>
<PopoverTrigger asChild>
<div
className={cn(
"border-input bg-background flex h-full w-full cursor-pointer items-center rounded-md border px-3",
readonly && "cursor-not-allowed opacity-50",
!selectedDate && !isTyping && "text-muted-foreground",
borderClass,
)}
onClick={() => { if (!readonly) setIsOpen(true); }}
>
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
<input
type="text"
inputMode="numeric"
value={isTyping ? typingValue : (formatDisplayValue() || "")}
placeholder={placeholder || config?.placeholder || "날짜를 선택하세요..."}
disabled={readonly}
onChange={(e) => handleTriggerInput(e.target.value)}
onClick={(e) => e.stopPropagation()}
onFocus={() => { if (!readonly && !isOpen) setIsOpen(true); }}
onBlur={() => { if (!isOpen) setIsTyping(false); }}
className="h-full w-full truncate bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
/>
{selectedDate && !readonly && !isTyping && (
<X
className="text-muted-foreground hover:text-foreground ml-auto h-3.5 w-3.5 shrink-0 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
handleClear();
}}
/>
)}
</div>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
<div className="p-4">
<div className="mb-3 flex items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleSetToday}>
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleClear}>
</Button>
</div>
{viewMode === "year" ? (
<>
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-sm font-medium">
{yearRangeStart} - {yearRangeStart + 11}
</div>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="mb-4 grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
<Button
key={year}
variant="ghost"
size="sm"
className={cn(
"h-9 text-xs",
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
)}
onClick={() => {
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
setViewMode("month");
}}
>
{year}
</Button>
))}
</div>
</>
) : viewMode === "month" ? (
<>
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<button
type="button"
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
onClick={() => {
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
setViewMode("year");
}}
>
{currentMonth.getFullYear()}
</button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="mb-4 grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
<Button
key={month}
variant="ghost"
size="sm"
className={cn(
"h-9 text-xs",
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
)}
onClick={() => {
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
setViewMode("calendar");
}}
>
{month + 1}
</Button>
))}
</div>
</>
) : (
<>
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<button
type="button"
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
onClick={() => {
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
setViewMode("year");
}}
>
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
</button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="mb-2 grid grid-cols-7 gap-1">
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
{day}
</div>
))}
</div>
<div className="mb-4 grid grid-cols-7 gap-1">
{allDays.map((date, index) => {
if (!date) return <div key={index} className="p-2" />;
const isCurrentMonth = isSameMonth(date, currentMonth);
const isSelected = selectedDate ? isSameDay(date, selectedDate) : false;
const isTodayDate = isToday(date);
return (
<Button
key={date.toISOString()}
variant="ghost"
size="sm"
className={cn(
"h-8 w-8 p-0 text-xs",
!isCurrentMonth && "text-muted-foreground opacity-50",
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
isTodayDate && !isSelected && "border-primary border",
)}
onClick={() => handleDateClick(date)}
disabled={!isCurrentMonth}
>
{format(date, "d")}
</Button>
);
})}
</div>
</>
)}
{/* datetime 타입: 시간 입력 */}
{isDatetime && viewMode === "calendar" && (
<div className="mb-4 flex items-center gap-2">
<span className="text-muted-foreground text-xs">:</span>
<input
type="time"
value={timeValue}
onChange={(e) => handleTimeChange(e.target.value)}
className="border-input h-8 rounded-md border px-2 text-xs"
/>
</div>
)}
</div>
</PopoverContent>
</Popover>
);
};
DateWidget.displayName = "DateWidget";

View File

@ -3,7 +3,7 @@
import React, { useState, useEffect, useMemo } from "react";
import { apiClient } from "@/lib/api/client";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
import { FolderTree, Loader2, Search, X } from "lucide-react";
import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react";
import { Input } from "@/components/ui/input";
interface CategoryColumn {
@ -30,6 +30,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
const [columns, setColumns] = useState<CategoryColumn[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
// 검색어로 필터링된 컬럼 목록
const filteredColumns = useMemo(() => {
@ -49,6 +50,44 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
});
}, [columns, searchQuery]);
// 테이블별로 그룹화된 컬럼 목록
const groupedColumns = useMemo(() => {
const groups: { tableName: string; tableLabel: string; columns: CategoryColumn[] }[] = [];
const groupMap = new Map<string, CategoryColumn[]>();
for (const col of filteredColumns) {
const key = col.tableName;
if (!groupMap.has(key)) {
groupMap.set(key, []);
}
groupMap.get(key)!.push(col);
}
for (const [tblName, cols] of groupMap) {
groups.push({
tableName: tblName,
tableLabel: cols[0]?.tableLabel || tblName,
columns: cols,
});
}
return groups;
}, [filteredColumns]);
// 선택된 컬럼이 있는 그룹을 자동 펼침
useEffect(() => {
if (!selectedColumn) return;
const tableName = selectedColumn.split(".")[0];
if (tableName) {
setExpandedGroups((prev) => {
if (prev.has(tableName)) return prev;
const next = new Set(prev);
next.add(tableName);
return next;
});
}
}, [selectedColumn]);
useEffect(() => {
// 메뉴 종속 없이 항상 회사 기준으로 카테고리 컬럼 조회
loadCategoryColumnsByMenu();
@ -279,35 +318,114 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
)}
</div>
<div className="space-y-2">
<div className="space-y-1">
{filteredColumns.length === 0 && searchQuery ? (
<div className="text-muted-foreground py-4 text-center text-xs">
&apos;{searchQuery}&apos;
</div>
) : null}
{filteredColumns.map((column) => {
const uniqueKey = `${column.tableName}.${column.columnName}`;
const isSelected = selectedColumn === uniqueKey; // 테이블명.컬럼명으로 비교
return (
<div
key={uniqueKey}
onClick={() => onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)}
className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${
isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
}`}
>
<div className="flex items-center gap-2">
<FolderTree
className={`h-4 w-4 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
/>
<div className="flex-1">
<h4 className="text-sm font-semibold">{column.columnLabel || column.columnName}</h4>
<p className="text-muted-foreground text-xs">{column.tableLabel || column.tableName}</p>
</div>
<span className="text-muted-foreground text-xs font-medium">
{column.valueCount !== undefined ? `${column.valueCount}` : "..."}
</span>
{groupedColumns.map((group) => {
const isExpanded = expandedGroups.has(group.tableName);
const totalValues = group.columns.reduce((sum, c) => sum + (c.valueCount ?? 0), 0);
const hasSelectedInGroup = group.columns.some(
(c) => selectedColumn === `${c.tableName}.${c.columnName}`,
);
// 그룹이 1개뿐이면 드롭다운 없이 바로 표시
if (groupedColumns.length <= 1) {
return (
<div key={group.tableName} className="space-y-1.5">
{group.columns.map((column) => {
const uniqueKey = `${column.tableName}.${column.columnName}`;
const isSelected = selectedColumn === uniqueKey;
return (
<div
key={uniqueKey}
onClick={() => onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)}
className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${
isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
}`}
>
<div className="flex items-center gap-2">
<FolderTree
className={`h-4 w-4 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
/>
<div className="flex-1">
<h4 className="text-sm font-semibold">{column.columnLabel || column.columnName}</h4>
<p className="text-muted-foreground text-xs">{column.tableLabel || column.tableName}</p>
</div>
<span className="text-muted-foreground text-xs font-medium">
{column.valueCount !== undefined ? `${column.valueCount}` : "..."}
</span>
</div>
</div>
);
})}
</div>
);
}
return (
<div key={group.tableName} className="overflow-hidden rounded-lg border">
{/* 드롭다운 헤더 */}
<button
type="button"
onClick={() => {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(group.tableName)) {
next.delete(group.tableName);
} else {
next.add(group.tableName);
}
return next;
});
}}
className={`flex w-full items-center gap-2 px-3 py-2 text-left transition-colors ${
hasSelectedInGroup ? "bg-primary/5" : "hover:bg-muted/50"
}`}
>
<ChevronRight
className={`h-3.5 w-3.5 shrink-0 transition-transform duration-200 ${
isExpanded ? "rotate-90" : ""
} ${hasSelectedInGroup ? "text-primary" : "text-muted-foreground"}`}
/>
<span className={`flex-1 text-xs font-semibold ${hasSelectedInGroup ? "text-primary" : ""}`}>
{group.tableLabel}
</span>
<span className="text-muted-foreground text-[10px]">
{group.columns.length} / {totalValues}
</span>
</button>
{/* 펼쳐진 컬럼 목록 */}
{isExpanded && (
<div className="space-y-1 border-t px-2 py-2">
{group.columns.map((column) => {
const uniqueKey = `${column.tableName}.${column.columnName}`;
const isSelected = selectedColumn === uniqueKey;
return (
<div
key={uniqueKey}
onClick={() => onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)}
className={`cursor-pointer rounded-md px-3 py-1.5 transition-all ${
isSelected ? "bg-primary/10 font-semibold text-primary" : "hover:bg-muted/50"
}`}
>
<div className="flex items-center gap-2">
<FolderTree
className={`h-3.5 w-3.5 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
/>
<span className="flex-1 text-xs">{column.columnLabel || column.columnName}</span>
<span className="text-muted-foreground text-[10px]">
{column.valueCount !== undefined ? `${column.valueCount}` : "..."}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
);
})}

View File

@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-[999] bg-black/80",
"fixed inset-0 z-[1050] bg-black/80",
className,
)}
{...props}
@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"bg-background fixed top-[50%] left-[50%] z-[1000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
"bg-background fixed top-[50%] left-[50%] z-[1100] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
className,
)}
{...props}

View File

@ -10,14 +10,13 @@
* - range 옵션: 범위 (~)
*/
import React, { forwardRef, useCallback, useMemo, useState } from "react";
import { format, parse, isValid } from "date-fns";
import React, { forwardRef, useCallback, useMemo, useState, useEffect } from "react";
import { format, parse, isValid, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isSameDay, isToday as isTodayFn } from "date-fns";
import { ko } from "date-fns/locale";
import { Calendar as CalendarIcon, Clock } from "lucide-react";
import { Calendar as CalendarIcon, Clock, ChevronLeft, ChevronRight } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { V2DateProps, V2DateType } from "@/types/v2-components";
@ -60,11 +59,24 @@ function formatDate(date: Date | undefined, formatStr: string): string {
return format(date, dateFnsFormat);
}
// YYYYMMDD 또는 YYYY-MM-DD 문자열 → 유효한 Date 객체 반환 (유효하지 않으면 null)
function parseManualDateInput(raw: string): Date | null {
const digits = raw.replace(/\D/g, "");
if (digits.length !== 8) return null;
const y = parseInt(digits.slice(0, 4), 10);
const m = parseInt(digits.slice(4, 6), 10) - 1;
const d = parseInt(digits.slice(6, 8), 10);
const date = new Date(y, m, d);
if (date.getFullYear() !== y || date.getMonth() !== m || date.getDate() !== d) return null;
if (y < 1900 || y > 2100) return null;
return date;
}
/**
*
*/
const SingleDatePicker = forwardRef<
HTMLButtonElement,
HTMLDivElement,
{
value?: string;
onChange?: (value: string) => void;
@ -83,80 +95,227 @@ const SingleDatePicker = forwardRef<
ref,
) => {
const [open, setOpen] = useState(false);
const [currentMonth, setCurrentMonth] = useState(new Date());
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
const [isTyping, setIsTyping] = useState(false);
const [typingValue, setTypingValue] = useState("");
const inputRef = React.useRef<HTMLInputElement>(null);
const date = useMemo(() => parseDate(value, dateFormat), [value, dateFormat]);
const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]);
const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]);
// 표시할 날짜 텍스트 계산 (ISO 형식이면 포맷팅, 아니면 그대로)
const displayText = useMemo(() => {
if (!value) return "";
// Date 객체로 변환 후 포맷팅
if (date && isValid(date)) {
return formatDate(date, dateFormat);
}
if (date && isValid(date)) return formatDate(date, dateFormat);
return value;
}, [value, date, dateFormat]);
const handleSelect = useCallback(
(selectedDate: Date | undefined) => {
if (selectedDate) {
onChange?.(formatDate(selectedDate, dateFormat));
setOpen(false);
useEffect(() => {
if (open) {
setViewMode("calendar");
if (date && isValid(date)) {
setCurrentMonth(new Date(date.getFullYear(), date.getMonth(), 1));
setYearRangeStart(Math.floor(date.getFullYear() / 12) * 12);
} else {
setCurrentMonth(new Date());
setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12);
}
},
[dateFormat, onChange],
);
} else {
setIsTyping(false);
setTypingValue("");
}
}, [open]);
const handleDateClick = useCallback((clickedDate: Date) => {
onChange?.(formatDate(clickedDate, dateFormat));
setIsTyping(false);
setOpen(false);
}, [dateFormat, onChange]);
const handleToday = useCallback(() => {
onChange?.(formatDate(new Date(), dateFormat));
setIsTyping(false);
setOpen(false);
}, [dateFormat, onChange]);
const handleClear = useCallback(() => {
onChange?.("");
setIsTyping(false);
setOpen(false);
}, [onChange]);
const handleTriggerInput = useCallback((raw: string) => {
setIsTyping(true);
setTypingValue(raw);
if (!open) setOpen(true);
const digitsOnly = raw.replace(/\D/g, "");
if (digitsOnly.length === 8) {
const parsed = parseManualDateInput(digitsOnly);
if (parsed) {
onChange?.(formatDate(parsed, dateFormat));
setCurrentMonth(new Date(parsed.getFullYear(), parsed.getMonth(), 1));
setTimeout(() => { setIsTyping(false); setOpen(false); }, 400);
}
}
}, [dateFormat, onChange, open]);
const mStart = startOfMonth(currentMonth);
const mEnd = endOfMonth(currentMonth);
const days = eachDayOfInterval({ start: mStart, end: mEnd });
const dow = mStart.getDay();
const padding = dow === 0 ? 6 : dow - 1;
const allDays = [...Array(padding).fill(null), ...days];
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={(v) => { if (!v) { setOpen(false); setIsTyping(false); } }}>
<PopoverTrigger asChild>
<Button
<div
ref={ref}
variant="outline"
disabled={disabled || readonly}
className={cn(
"h-full w-full justify-start text-left font-normal",
!displayText && "text-muted-foreground",
"border-input bg-background flex h-full w-full cursor-pointer items-center rounded-md border px-3",
(disabled || readonly) && "cursor-not-allowed opacity-50",
className,
)}
onClick={() => { if (!disabled && !readonly) setOpen(true); }}
>
<CalendarIcon className="mr-2 h-4 w-4 flex-shrink-0" />
{displayText || placeholder}
</Button>
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
<input
ref={inputRef}
type="text"
inputMode="numeric"
value={isTyping ? typingValue : (displayText || "")}
placeholder={placeholder}
disabled={disabled || readonly}
onChange={(e) => handleTriggerInput(e.target.value)}
onClick={(e) => e.stopPropagation()}
onFocus={() => { if (!disabled && !readonly && !open) setOpen(true); }}
onBlur={() => { if (!open) setIsTyping(false); }}
className={cn(
"h-full w-full bg-transparent text-sm outline-none",
"placeholder:text-muted-foreground disabled:cursor-not-allowed",
!displayText && !isTyping && "text-muted-foreground",
)}
/>
</div>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={handleSelect}
initialFocus
locale={ko}
disabled={(date) => {
if (minDateObj && date < minDateObj) return true;
if (maxDateObj && date > maxDateObj) return true;
return false;
}}
/>
<div className="flex gap-2 p-3 pt-0">
{showToday && (
<Button variant="outline" size="sm" onClick={handleToday}>
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
<div className="p-4">
<div className="mb-3 flex items-center gap-2">
{showToday && (
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleToday}>
</Button>
)}
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleClear}>
</Button>
</div>
{viewMode === "year" ? (
<>
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-sm font-medium">{yearRangeStart} - {yearRangeStart + 11}</div>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
<Button
key={year}
variant="ghost"
size="sm"
className={cn(
"h-9 text-xs",
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
)}
onClick={() => { setCurrentMonth(new Date(year, currentMonth.getMonth(), 1)); setViewMode("month"); }}
>
{year}
</Button>
))}
</div>
</>
) : viewMode === "month" ? (
<>
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<button type="button" className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors" onClick={() => { setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12); setViewMode("year"); }}>
{currentMonth.getFullYear()}
</button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
<Button
key={month}
variant="ghost"
size="sm"
className={cn(
"h-9 text-xs",
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
)}
onClick={() => { setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1)); setViewMode("calendar"); }}
>
{month + 1}
</Button>
))}
</div>
</>
) : (
<>
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<button type="button" className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors" onClick={() => { setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12); setViewMode("year"); }}>
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
</button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="mb-2 grid grid-cols-7 gap-1">
{["월", "화", "수", "목", "금", "토", "일"].map((d) => (
<div key={d} className="text-muted-foreground p-2 text-center text-xs font-medium">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{allDays.map((d, idx) => {
if (!d) return <div key={idx} className="p-2" />;
const isCur = isSameMonth(d, currentMonth);
const isSel = date ? isSameDay(d, date) : false;
const isT = isTodayFn(d);
return (
<Button
key={d.toISOString()}
variant="ghost"
size="sm"
className={cn(
"h-8 w-8 p-0 text-xs",
!isCur && "text-muted-foreground opacity-50",
isSel && "bg-primary text-primary-foreground hover:bg-primary",
isT && !isSel && "border-primary border",
)}
onClick={() => handleDateClick(d)}
disabled={!isCur}
>
{format(d, "d")}
</Button>
);
})}
</div>
</>
)}
<Button variant="ghost" size="sm" onClick={handleClear}>
</Button>
</div>
</PopoverContent>
</Popover>
@ -168,6 +327,149 @@ SingleDatePicker.displayName = "SingleDatePicker";
/**
*
*/
/**
* (drill-down )
*/
const RangeCalendarPopover: React.FC<{
open: boolean;
onOpenChange: (open: boolean) => void;
selectedDate?: Date;
onSelect: (date: Date) => void;
label: string;
disabled?: boolean;
readonly?: boolean;
displayValue?: string;
}> = ({ open, onOpenChange, selectedDate, onSelect, label, disabled, readonly, displayValue }) => {
const [currentMonth, setCurrentMonth] = useState(new Date());
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
const [isTyping, setIsTyping] = useState(false);
const [typingValue, setTypingValue] = useState("");
useEffect(() => {
if (open) {
setViewMode("calendar");
if (selectedDate && isValid(selectedDate)) {
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
setYearRangeStart(Math.floor(selectedDate.getFullYear() / 12) * 12);
} else {
setCurrentMonth(new Date());
setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12);
}
} else {
setIsTyping(false);
setTypingValue("");
}
}, [open]);
const handleTriggerInput = (raw: string) => {
setIsTyping(true);
setTypingValue(raw);
const digitsOnly = raw.replace(/\D/g, "");
if (digitsOnly.length === 8) {
const parsed = parseManualDateInput(digitsOnly);
if (parsed) {
setIsTyping(false);
onSelect(parsed);
}
}
};
const mStart = startOfMonth(currentMonth);
const mEnd = endOfMonth(currentMonth);
const days = eachDayOfInterval({ start: mStart, end: mEnd });
const dow = mStart.getDay();
const padding = dow === 0 ? 6 : dow - 1;
const allDays = [...Array(padding).fill(null), ...days];
return (
<Popover open={open} onOpenChange={(v) => { if (!v) { setIsTyping(false); } onOpenChange(v); }}>
<PopoverTrigger asChild>
<div
className={cn(
"border-input bg-background flex h-full flex-1 cursor-pointer items-center rounded-md border px-3",
(disabled || readonly) && "cursor-not-allowed opacity-50",
!displayValue && !isTyping && "text-muted-foreground",
)}
onClick={() => { if (!disabled && !readonly) onOpenChange(true); }}
>
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
<input
type="text"
inputMode="numeric"
value={isTyping ? typingValue : (displayValue || "")}
placeholder={label}
disabled={disabled || readonly}
onChange={(e) => handleTriggerInput(e.target.value)}
onClick={(e) => e.stopPropagation()}
onFocus={() => { if (!disabled && !readonly && !open) onOpenChange(true); }}
onBlur={() => { if (!open) setIsTyping(false); }}
className="h-full w-full bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
/>
</div>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
<div className="p-4">
{viewMode === "year" ? (
<>
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}><ChevronLeft className="h-4 w-4" /></Button>
<div className="text-sm font-medium">{yearRangeStart} - {yearRangeStart + 11}</div>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}><ChevronRight className="h-4 w-4" /></Button>
</div>
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
<Button key={year} variant="ghost" size="sm" className={cn("h-9 text-xs", year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary", year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border")}
onClick={() => { setCurrentMonth(new Date(year, currentMonth.getMonth(), 1)); setViewMode("month"); }}>{year}</Button>
))}
</div>
</>
) : viewMode === "month" ? (
<>
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}><ChevronLeft className="h-4 w-4" /></Button>
<button type="button" className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors" onClick={() => { setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12); setViewMode("year"); }}>{currentMonth.getFullYear()}</button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}><ChevronRight className="h-4 w-4" /></Button>
</div>
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
<Button key={month} variant="ghost" size="sm" className={cn("h-9 text-xs", month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary", month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border")}
onClick={() => { setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1)); setViewMode("calendar"); }}>{month + 1}</Button>
))}
</div>
</>
) : (
<>
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}><ChevronLeft className="h-4 w-4" /></Button>
<button type="button" className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors" onClick={() => { setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12); setViewMode("year"); }}>{format(currentMonth, "yyyy년 MM월", { locale: ko })}</button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}><ChevronRight className="h-4 w-4" /></Button>
</div>
<div className="mb-2 grid grid-cols-7 gap-1">
{["월", "화", "수", "목", "금", "토", "일"].map((d) => (
<div key={d} className="text-muted-foreground p-2 text-center text-xs font-medium">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{allDays.map((d, idx) => {
if (!d) return <div key={idx} className="p-2" />;
const isCur = isSameMonth(d, currentMonth);
const isSel = selectedDate ? isSameDay(d, selectedDate) : false;
const isT = isTodayFn(d);
return (
<Button key={d.toISOString()} variant="ghost" size="sm" className={cn("h-8 w-8 p-0 text-xs", !isCur && "text-muted-foreground opacity-50", isSel && "bg-primary text-primary-foreground hover:bg-primary", isT && !isSel && "border-primary border")}
onClick={() => onSelect(d)} disabled={!isCur}>{format(d, "d")}</Button>
);
})}
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
);
};
const RangeDatePicker = forwardRef<
HTMLDivElement,
{
@ -186,102 +488,38 @@ const RangeDatePicker = forwardRef<
const startDate = useMemo(() => parseDate(value[0], dateFormat), [value, dateFormat]);
const endDate = useMemo(() => parseDate(value[1], dateFormat), [value, dateFormat]);
const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]);
const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]);
const handleStartSelect = useCallback(
(date: Date | undefined) => {
if (date) {
const newStart = formatDate(date, dateFormat);
// 시작일이 종료일보다 크면 종료일도 같이 변경
if (endDate && date > endDate) {
onChange?.([newStart, newStart]);
} else {
onChange?.([newStart, value[1]]);
}
setOpenStart(false);
(date: Date) => {
const newStart = formatDate(date, dateFormat);
if (endDate && date > endDate) {
onChange?.([newStart, newStart]);
} else {
onChange?.([newStart, value[1]]);
}
setOpenStart(false);
},
[value, dateFormat, endDate, onChange],
);
const handleEndSelect = useCallback(
(date: Date | undefined) => {
if (date) {
const newEnd = formatDate(date, dateFormat);
// 종료일이 시작일보다 작으면 시작일도 같이 변경
if (startDate && date < startDate) {
onChange?.([newEnd, newEnd]);
} else {
onChange?.([value[0], newEnd]);
}
setOpenEnd(false);
(date: Date) => {
const newEnd = formatDate(date, dateFormat);
if (startDate && date < startDate) {
onChange?.([newEnd, newEnd]);
} else {
onChange?.([value[0], newEnd]);
}
setOpenEnd(false);
},
[value, dateFormat, startDate, onChange],
);
return (
<div ref={ref} className={cn("flex items-center gap-2 h-full", className)}>
{/* 시작 날짜 */}
<Popover open={openStart} onOpenChange={setOpenStart}>
<PopoverTrigger asChild>
<Button
variant="outline"
disabled={disabled || readonly}
className={cn("h-full flex-1 justify-start text-left font-normal", !value[0] && "text-muted-foreground")}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value[0] || "시작일"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={startDate}
onSelect={handleStartSelect}
initialFocus
locale={ko}
disabled={(date) => {
if (minDateObj && date < minDateObj) return true;
if (maxDateObj && date > maxDateObj) return true;
return false;
}}
/>
</PopoverContent>
</Popover>
<RangeCalendarPopover open={openStart} onOpenChange={setOpenStart} selectedDate={startDate} onSelect={handleStartSelect} label="시작일" disabled={disabled} readonly={readonly} displayValue={value[0]} />
<span className="text-muted-foreground">~</span>
{/* 종료 날짜 */}
<Popover open={openEnd} onOpenChange={setOpenEnd}>
<PopoverTrigger asChild>
<Button
variant="outline"
disabled={disabled || readonly}
className={cn("h-full flex-1 justify-start text-left font-normal", !value[1] && "text-muted-foreground")}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value[1] || "종료일"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={endDate}
onSelect={handleEndSelect}
initialFocus
locale={ko}
disabled={(date) => {
if (minDateObj && date < minDateObj) return true;
if (maxDateObj && date > maxDateObj) return true;
// 시작일보다 이전 날짜는 선택 불가
if (startDate && date < startDate) return true;
return false;
}}
/>
</PopoverContent>
</Popover>
<RangeCalendarPopover open={openEnd} onOpenChange={setOpenEnd} selectedDate={endDate} onSelect={handleEndSelect} label="종료일" disabled={disabled} readonly={readonly} displayValue={value[1]} />
</div>
);
});

View File

@ -1328,9 +1328,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
)}
</div>
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
{/* 확인 다이얼로그 */}
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent className="z-[99999]">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
<AlertDialogDescription>{getConfirmMessage()}</AlertDialogDescription>

View File

@ -24,29 +24,33 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
style,
...props
}) => {
// 컴포넌트 설정
const componentConfig = {
...config,
...component.config,
} as ImageDisplayConfig;
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
const objectFit = componentConfig.objectFit || "contain";
const altText = componentConfig.altText || "이미지";
const borderRadius = componentConfig.borderRadius ?? 8;
const showBorder = componentConfig.showBorder ?? true;
const backgroundColor = componentConfig.backgroundColor || "#f9fafb";
const placeholder = componentConfig.placeholder || "이미지 없음";
const imageSrc = component.value || componentConfig.imageUrl || "";
const componentStyle: React.CSSProperties = {
width: "100%",
height: "100%",
...component.style,
...style,
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
width: "100%",
};
// 디자인 모드 스타일
if (isDesignMode) {
componentStyle.border = "1px dashed #cbd5e1";
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
}
// 이벤트 핸들러
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
@ -88,7 +92,9 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
}}
>
{component.label}
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
{(component.required || componentConfig.required) && (
<span style={{ color: "#ef4444" }}>*</span>
)}
</label>
)}
@ -96,43 +102,53 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
style={{
width: "100%",
height: "100%",
border: "1px solid #d1d5db",
borderRadius: "8px",
border: showBorder ? "1px solid #d1d5db" : "none",
borderRadius: `${borderRadius}px`,
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f9fafb",
backgroundColor,
transition: "all 0.2s ease-in-out",
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
boxShadow: showBorder ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : "none",
opacity: componentConfig.disabled ? 0.5 : 1,
cursor: componentConfig.disabled ? "not-allowed" : "default",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#f97316";
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)";
if (!componentConfig.disabled) {
if (showBorder) {
e.currentTarget.style.borderColor = "#f97316";
}
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)";
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "#d1d5db";
e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
if (showBorder) {
e.currentTarget.style.borderColor = "#d1d5db";
}
e.currentTarget.style.boxShadow = showBorder
? "0 1px 2px 0 rgba(0, 0, 0, 0.05)"
: "none";
}}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
{component.value || componentConfig.imageUrl ? (
{imageSrc ? (
<img
src={component.value || componentConfig.imageUrl}
alt={componentConfig.altText || "이미지"}
src={imageSrc}
alt={altText}
style={{
maxWidth: "100%",
maxHeight: "100%",
objectFit: componentConfig.objectFit || "contain",
objectFit,
}}
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
if (e.target?.parentElement) {
e.target.parentElement.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px; color: #6b7280; font-size: 14px;">
<div style="font-size: 24px;">🖼</div>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>
<div> </div>
</div>
`;
@ -150,8 +166,22 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
fontSize: "14px",
}}
>
<div style={{ fontSize: "32px" }}>🖼</div>
<div> </div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
<div>{placeholder}</div>
</div>
)}
</div>
@ -161,7 +191,6 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
/**
* ImageDisplay
*
*/
export const ImageDisplayWrapper: React.FC<ImageDisplayComponentProps> = (props) => {
return <ImageDisplayComponent {...props} />;

View File

@ -9,63 +9,166 @@ import { ImageDisplayConfig } from "./types";
export interface ImageDisplayConfigPanelProps {
config: ImageDisplayConfig;
onChange: (config: Partial<ImageDisplayConfig>) => void;
onChange?: (config: Partial<ImageDisplayConfig>) => void;
onConfigChange?: (config: Partial<ImageDisplayConfig>) => void;
}
/**
* ImageDisplay
* UI
*/
export const ImageDisplayConfigPanel: React.FC<ImageDisplayConfigPanelProps> = ({
config,
onChange,
onConfigChange,
}) => {
const handleChange = (key: keyof ImageDisplayConfig, value: any) => {
onChange({ [key]: value });
const update = { ...config, [key]: value };
onChange?.(update);
onConfigChange?.(update);
};
return (
<div className="space-y-4">
<div className="text-sm font-medium">
image-display
<div className="text-sm font-medium"> </div>
{/* 이미지 URL */}
<div className="space-y-2">
<Label htmlFor="imageUrl" className="text-xs">
URL
</Label>
<Input
id="imageUrl"
value={config.imageUrl || ""}
onChange={(e) => handleChange("imageUrl", e.target.value)}
placeholder="https://..."
className="h-8 text-xs"
/>
<p className="text-muted-foreground text-[10px]">
</p>
</div>
{/* file 관련 설정 */}
{/* 대체 텍스트 */}
<div className="space-y-2">
<Label htmlFor="placeholder"></Label>
<Label htmlFor="altText" className="text-xs">
(alt)
</Label>
<Input
id="altText"
value={config.altText || ""}
onChange={(e) => handleChange("altText", e.target.value)}
placeholder="이미지 설명"
className="h-8 text-xs"
/>
</div>
{/* 이미지 맞춤 */}
<div className="space-y-2">
<Label htmlFor="objectFit" className="text-xs">
(Object Fit)
</Label>
<Select
value={config.objectFit || "contain"}
onValueChange={(value) => handleChange("objectFit", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="contain">Contain ( , )</SelectItem>
<SelectItem value="cover">Cover ( , )</SelectItem>
<SelectItem value="fill">Fill ( )</SelectItem>
<SelectItem value="none">None ( )</SelectItem>
<SelectItem value="scale-down">Scale Down ()</SelectItem>
</SelectContent>
</Select>
</div>
{/* 테두리 둥글기 */}
<div className="space-y-2">
<Label htmlFor="borderRadius" className="text-xs">
(px)
</Label>
<Input
id="borderRadius"
type="number"
min="0"
max="50"
value={config.borderRadius ?? 8}
onChange={(e) => handleChange("borderRadius", parseInt(e.target.value) || 0)}
className="h-8 text-xs"
/>
</div>
{/* 배경 색상 */}
<div className="space-y-2">
<Label htmlFor="backgroundColor" className="text-xs">
</Label>
<div className="flex items-center gap-2">
<input
type="color"
value={config.backgroundColor || "#f9fafb"}
onChange={(e) => handleChange("backgroundColor", e.target.value)}
className="h-8 w-8 cursor-pointer rounded border"
/>
<Input
id="backgroundColor"
value={config.backgroundColor || "#f9fafb"}
onChange={(e) => handleChange("backgroundColor", e.target.value)}
className="h-8 flex-1 text-xs"
/>
</div>
</div>
{/* 플레이스홀더 */}
<div className="space-y-2">
<Label htmlFor="placeholder" className="text-xs">
</Label>
<Input
id="placeholder"
value={config.placeholder || ""}
onChange={(e) => handleChange("placeholder", e.target.value)}
placeholder="이미지 없음"
className="h-8 text-xs"
/>
</div>
{/* 공통 설정 */}
<div className="space-y-2">
<Label htmlFor="disabled"></Label>
{/* 테두리 표시 */}
<div className="flex items-center gap-2">
<Checkbox
id="disabled"
checked={config.disabled || false}
onCheckedChange={(checked) => handleChange("disabled", checked)}
id="showBorder"
checked={config.showBorder ?? true}
onCheckedChange={(checked) => handleChange("showBorder", checked)}
/>
<Label htmlFor="showBorder" className="text-xs cursor-pointer">
</Label>
</div>
<div className="space-y-2">
<Label htmlFor="required"> </Label>
<Checkbox
id="required"
checked={config.required || false}
onCheckedChange={(checked) => handleChange("required", checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="readonly"> </Label>
{/* 읽기 전용 */}
<div className="flex items-center gap-2">
<Checkbox
id="readonly"
checked={config.readonly || false}
onCheckedChange={(checked) => handleChange("readonly", checked)}
/>
<Label htmlFor="readonly" className="text-xs cursor-pointer">
</Label>
</div>
{/* 필수 입력 */}
<div className="flex items-center gap-2">
<Checkbox
id="required"
checked={config.required || false}
onCheckedChange={(checked) => handleChange("required", checked)}
/>
<Label htmlFor="required" className="text-xs cursor-pointer">
</Label>
</div>
</div>
);

View File

@ -6,9 +6,14 @@ import { ImageDisplayConfig } from "./types";
* ImageDisplay
*/
export const ImageDisplayDefaultConfig: ImageDisplayConfig = {
placeholder: "입력하세요",
imageUrl: "",
altText: "이미지",
objectFit: "contain",
borderRadius: 8,
showBorder: true,
backgroundColor: "#f9fafb",
placeholder: "이미지 없음",
// 공통 기본값
disabled: false,
required: false,
readonly: false,
@ -18,23 +23,31 @@ export const ImageDisplayDefaultConfig: ImageDisplayConfig = {
/**
* ImageDisplay
*
*/
export const ImageDisplayConfigSchema = {
placeholder: { type: "string", default: "" },
imageUrl: { type: "string", default: "" },
altText: { type: "string", default: "이미지" },
objectFit: {
type: "enum",
values: ["contain", "cover", "fill", "none", "scale-down"],
default: "contain",
},
borderRadius: { type: "number", default: 8 },
showBorder: { type: "boolean", default: true },
backgroundColor: { type: "string", default: "#f9fafb" },
placeholder: { type: "string", default: "이미지 없음" },
// 공통 스키마
disabled: { type: "boolean", default: false },
required: { type: "boolean", default: false },
readonly: { type: "boolean", default: false },
variant: {
type: "enum",
values: ["default", "outlined", "filled"],
default: "default"
default: "default",
},
size: {
type: "enum",
values: ["sm", "md", "lg"],
default: "md"
default: "md",
},
};

View File

@ -21,7 +21,13 @@ export const ImageDisplayDefinition = createComponentDefinition({
webType: "file",
component: ImageDisplayWrapper,
defaultConfig: {
placeholder: "입력하세요",
imageUrl: "",
altText: "이미지",
objectFit: "contain",
borderRadius: 8,
showBorder: true,
backgroundColor: "#f9fafb",
placeholder: "이미지 없음",
},
defaultSize: { width: 200, height: 200 },
configPanel: ImageDisplayConfigPanel,

View File

@ -6,15 +6,19 @@ import { ComponentConfig } from "@/types/component";
* ImageDisplay
*/
export interface ImageDisplayConfig extends ComponentConfig {
// file 관련 설정
// 이미지 관련 설정
imageUrl?: string;
altText?: string;
objectFit?: "contain" | "cover" | "fill" | "none" | "scale-down";
borderRadius?: number;
showBorder?: boolean;
backgroundColor?: string;
placeholder?: string;
// 공통 설정
disabled?: boolean;
required?: boolean;
readonly?: boolean;
placeholder?: string;
helperText?: string;
// 스타일 관련
variant?: "default" | "outlined" | "filled";

View File

@ -28,6 +28,7 @@ import { apiClient } from "@/lib/api/client";
import { allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig } from "@/types/screen-management";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import {
UniversalFormModalComponentProps,
@ -1835,11 +1836,11 @@ export function UniversalFormModalComponent({
case "date":
return (
<Input
<FormDatePicker
id={fieldKey}
type="date"
value={value || ""}
onChange={(e) => onChangeHandler(e.target.value)}
onChange={onChangeHandler}
placeholder={field.placeholder || "날짜를 선택하세요"}
disabled={isDisabled}
readOnly={field.readOnly}
/>
@ -1847,13 +1848,14 @@ export function UniversalFormModalComponent({
case "datetime":
return (
<Input
<FormDatePicker
id={fieldKey}
type="datetime-local"
value={value || ""}
onChange={(e) => onChangeHandler(e.target.value)}
onChange={onChangeHandler}
placeholder={field.placeholder || "날짜/시간을 선택하세요"}
disabled={isDisabled}
readOnly={field.readOnly}
includeTime
/>
);

View File

@ -1481,9 +1481,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
)}
</div>
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
{/* 확인 다이얼로그 */}
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent className="z-[99999]">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
<AlertDialogDescription>{getConfirmMessage()}</AlertDialogDescription>

View File

@ -247,14 +247,12 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
</DialogHeader>
<div className="flex h-[75vh] flex-col space-y-3">
{/* 파일 업로드 영역 - 높이 축소 */}
{!isDesignMode && (
{/* 파일 업로드 영역 - readonly/disabled이면 숨김 */}
{!isDesignMode && !config.readonly && !config.disabled && (
<div
className={`cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors ${dragOver ? "border-blue-400 bg-blue-50" : "border-gray-300"} ${config.disabled ? "cursor-not-allowed opacity-50" : "hover:border-gray-400"} ${uploading ? "opacity-75" : ""} `}
className={`cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors ${dragOver ? "border-blue-400 bg-blue-50" : "border-gray-300"} hover:border-gray-400 ${uploading ? "opacity-75" : ""} `}
onClick={() => {
if (!config.disabled && !isDesignMode) {
fileInputRef.current?.click();
}
fileInputRef.current?.click();
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@ -267,7 +265,6 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
accept={config.accept}
onChange={handleFileInputChange}
className="hidden"
disabled={config.disabled}
/>
{uploading ? (
@ -286,8 +283,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
{/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
<div className="flex min-h-0 flex-1 gap-4">
{/* 좌측: 이미지 미리보기 (확대/축소 가능) */}
<div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border border-gray-200 bg-gray-900">
{/* 좌측: 이미지 미리보기 (확대/축소 가능) - showPreview가 false면 숨김 */}
{(config.showPreview !== false) && <div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border border-gray-200 bg-gray-900">
{/* 확대/축소 컨트롤 */}
{selectedFile && previewImageUrl && (
<div className="absolute top-3 left-3 z-10 flex items-center gap-1 rounded-lg bg-black/60 p-1">
@ -369,10 +366,10 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
{selectedFile.realFileName}
</div>
)}
</div>
</div>}
{/* 우측: 파일 목록 (고정 너비) */}
<div className="flex w-[400px] shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200">
{/* 우측: 파일 목록 - showFileList가 false면 숨김, showPreview가 false면 전체 너비 */}
{(config.showFileList !== false) && <div className={`flex shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200 ${config.showPreview !== false ? "w-[400px]" : "flex-1"}`}>
<div className="border-b border-gray-200 bg-gray-50 p-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700"> </h3>
@ -404,7 +401,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
)}
</div>
<p className="text-xs text-gray-500">
{formatFileSize(file.fileSize)} {file.fileExt.toUpperCase()}
{config.showFileSize !== false && <>{formatFileSize(file.fileSize)} </>}{file.fileExt.toUpperCase()}
</p>
</div>
<div className="flex items-center space-x-1">
@ -434,19 +431,21 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
>
<Eye className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
onFileDownload(file);
}}
title="다운로드"
>
<Download className="h-3 w-3" />
</Button>
{!isDesignMode && (
{config.allowDownload !== false && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
onFileDownload(file);
}}
title="다운로드"
>
<Download className="h-3 w-3" />
</Button>
)}
{!isDesignMode && config.allowDelete !== false && (
<Button
variant="ghost"
size="sm"
@ -476,7 +475,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
</div>
)}
</div>
</div>
</div>}
</div>
</div>
</DialogContent>
@ -487,8 +486,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
file={viewerFile}
isOpen={isViewerOpen}
onClose={handleViewerClose}
onDownload={onFileDownload}
onDelete={!isDesignMode ? onFileDelete : undefined}
onDownload={config.allowDownload !== false ? onFileDownload : undefined}
onDelete={!isDesignMode && config.allowDelete !== false ? onFileDelete : undefined}
/>
</>
);

View File

@ -105,6 +105,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const [forceUpdate, setForceUpdate] = useState(0);
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// objid 기반으로 파일이 로드되었는지 추적 (다른 이펙트가 덮어쓰지 않도록 방지)
const filesLoadedFromObjidRef = useRef(false);
// 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
@ -150,6 +152,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
if (isRecordMode || !recordId) {
setUploadedFiles([]);
setRepresentativeImageUrl(null);
filesLoadedFromObjidRef.current = false;
}
} else if (prevIsRecordModeRef.current === null) {
// 초기 마운트 시 모드 저장
@ -191,63 +194,68 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}, [component.id, getUniqueKey, recordId, isRecordMode]); // 레코드별 고유 키 변경 시 재실행
// 🔑 수정 모드: formData[columnName]에 저장된 objid로 이미지 로드
// 🆕 formData 전체가 아닌 특정 컬럼 값만 의존하도록 수정 (다른 컴포넌트 영향 방지)
// 콤마로 구분된 다중 objid도 처리 (예: "123,456")
const imageObjidFromFormData = formData?.[columnName];
useEffect(() => {
// 이미지 objid가 있고, 숫자 문자열인 경우에만 처리
if (imageObjidFromFormData && /^\d+$/.test(String(imageObjidFromFormData))) {
const objidStr = String(imageObjidFromFormData);
if (!imageObjidFromFormData) return;
// 이미 같은 objid의 파일이 로드되어 있으면 스킵
const alreadyLoaded = uploadedFiles.some(f => String(f.objid) === objidStr);
if (alreadyLoaded) {
return;
}
const rawValue = String(imageObjidFromFormData);
// 콤마 구분 다중 objid 또는 단일 objid 모두 처리
const objids = rawValue.split(',').map(s => s.trim()).filter(s => /^\d+$/.test(s));
// 🔑 실제 파일 정보 조회 (previewUrl 제거 - apiClient blob 다운로드 방식으로 통일)
(async () => {
try {
const fileInfoResponse = await getFileInfoByObjid(objidStr);
if (objids.length === 0) return;
// 모든 objid가 이미 로드되어 있으면 스킵
const allLoaded = objids.every(id => uploadedFiles.some(f => String(f.objid) === id));
if (allLoaded) return;
(async () => {
try {
const loadedFiles: FileInfo[] = [];
for (const objid of objids) {
// 이미 로드된 파일은 스킵
if (uploadedFiles.some(f => String(f.objid) === objid)) continue;
const fileInfoResponse = await getFileInfoByObjid(objid);
if (fileInfoResponse.success && fileInfoResponse.data) {
const { realFileName, fileSize, fileExt, regdate, isRepresentative } = fileInfoResponse.data;
const fileInfo = {
objid: objidStr,
realFileName: realFileName,
fileExt: fileExt,
fileSize: fileSize,
filePath: getFilePreviewUrl(objidStr),
regdate: regdate,
loadedFiles.push({
objid,
realFileName,
fileExt,
fileSize,
filePath: getFilePreviewUrl(objid),
regdate,
isImage: true,
isRepresentative: isRepresentative,
};
setUploadedFiles([fileInfo]);
// representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨
isRepresentative,
} as FileInfo);
} else {
// 파일 정보 조회 실패 시 최소 정보로 추가
console.warn("🖼️ [FileUploadComponent] 파일 정보 조회 실패, 최소 정보 사용");
const minimalFileInfo = {
objid: objidStr,
realFileName: `image_${objidStr}.jpg`,
loadedFiles.push({
objid,
realFileName: `file_${objid}`,
fileExt: '.jpg',
fileSize: 0,
filePath: getFilePreviewUrl(objidStr),
filePath: getFilePreviewUrl(objid),
regdate: new Date().toISOString(),
isImage: true,
};
setUploadedFiles([minimalFileInfo]);
// representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨
} as FileInfo);
}
} catch (error) {
console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error);
}
})();
}
}, [imageObjidFromFormData, columnName, component.id]); // 🆕 formData 대신 특정 컬럼 값만 의존
if (loadedFiles.length > 0) {
setUploadedFiles(loadedFiles);
filesLoadedFromObjidRef.current = true;
}
} catch (error) {
console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error);
}
})();
}, [imageObjidFromFormData, columnName, component.id]);
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
// 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
@ -365,6 +373,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
...file,
}));
// 서버에서 0개 반환 + objid 기반 로딩이 이미 완료된 경우 덮어쓰지 않음
if (formattedFiles.length === 0 && filesLoadedFromObjidRef.current) {
return false;
}
// 🔄 localStorage의 기존 파일과 서버 파일 병합 (레코드별 고유 키 사용)
let finalFiles = formattedFiles;
@ -427,14 +439,19 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
return; // DB 로드 성공 시 localStorage 무시
}
// 🆕 등록 모드(새 레코드)인 경우 fallback 로드도 스킵 - 항상 빈 상태 유지
// objid 기반으로 이미 파일이 로드된 경우 빈 데이터로 덮어쓰지 않음
if (filesLoadedFromObjidRef.current) {
return;
}
// 등록 모드(새 레코드)인 경우 fallback 로드도 스킵 - 항상 빈 상태 유지
if (!isRecordMode || !recordId) {
return;
}
// DB 로드 실패 시에만 기존 로직 사용 (하위 호환성)
// 전역 상태에서 최신 파일 정보 가져오기 (🆕 고유 키 사용)
// 전역 상태에서 최신 파일 정보 가져오기 (고유 키 사용)
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
const uniqueKeyForFallback = getUniqueKey();
const globalFiles = globalFileState[uniqueKeyForFallback] || globalFileState[component.id] || [];
@ -442,6 +459,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
// 빈 데이터로 기존 파일을 덮어쓰지 않음
if (currentFiles.length === 0) {
return;
}
// 최신 파일과 현재 파일 비교
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
@ -1147,8 +1168,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
file={viewerFile}
isOpen={isViewerOpen}
onClose={handleViewerClose}
onDownload={handleFileDownload}
onDelete={!isDesignMode ? handleFileDelete : undefined}
onDownload={safeComponentConfig.allowDownload !== false ? handleFileDownload : undefined}
onDelete={!isDesignMode && safeComponentConfig.allowDelete !== false ? handleFileDelete : undefined}
/>
{/* 파일 관리 모달 */}

View File

@ -2172,7 +2172,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const handleRowClick = (row: any, index: number, e: React.MouseEvent) => {
// 체크박스 클릭은 무시 (이미 handleRowSelection에서 처리됨)
const target = e.target as HTMLElement;
if (target.closest('input[type="checkbox"]')) {
if (target.closest('input[type="checkbox"]') || target.closest('button[role="checkbox"]')) {
return;
}
@ -2198,35 +2198,32 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
};
// 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택)
// 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택/해제 토글)
const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => {
e.stopPropagation();
setFocusedCell({ rowIndex, colIndex });
// 테이블 컨테이너에 포커스 설정 (키보드 이벤트 수신용)
tableContainerRef.current?.focus();
// 🆕 분할 패널 내에서 셀 클릭 시에도 해당 행 선택 처리
// filteredData에서 해당 행의 데이터 가져오기
const row = filteredData[rowIndex];
if (!row) return;
// 체크박스 컬럼은 Checkbox의 onCheckedChange에서 이미 처리되므로 스킵
const column = visibleColumns[colIndex];
if (column?.columnName === "__checkbox__") return;
const rowKey = getRowKey(row, rowIndex);
const isCurrentlySelected = selectedRows.has(rowKey);
// 분할 패널 컨텍스트가 있고, 좌측 화면인 경우에만 행 선택 및 데이터 전달
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
// 이미 선택된 행과 다른 행을 클릭한 경우에만 처리
// 분할 패널 좌측: 단일 행 선택 모드
if (!isCurrentlySelected) {
// 기존 선택 해제하고 새 행 선택
setSelectedRows(new Set([rowKey]));
setIsAllSelected(false);
// 분할 패널 컨텍스트에 데이터 저장
splitPanelContext.setSelectedLeftData(row);
// onSelectedRowsChange 콜백 호출
if (onSelectedRowsChange) {
onSelectedRowsChange([rowKey], [row], sortColumn || undefined, sortDirection);
}
@ -2234,6 +2231,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onFormDataChange({ selectedRows: [rowKey], selectedRowsData: [row] });
}
}
} else {
// 일반 모드: 행 선택/해제 토글
handleRowSelection(rowKey, !isCurrentlySelected);
if (splitPanelContext && effectiveSplitPosition === "left") {
if (!isCurrentlySelected) {
splitPanelContext.setSelectedLeftData(row);
} else {
splitPanelContext.setSelectedLeftData(null);
}
}
}
};
@ -6309,6 +6317,21 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
);
}
// 날짜 타입: 캘린더 피커
const isDateType = colMeta?.inputType === "date" || colMeta?.inputType === "datetime";
if (isDateType) {
const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker");
return (
<InlineCellDatePicker
value={editingValue}
onChange={setEditingValue}
onSave={saveEditing}
onKeyDown={handleEditKeyDown}
inputRef={editInputRef}
/>
);
}
// 일반 입력 필드
return (
<input

View File

@ -437,7 +437,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTable?.tableName, filterMode, screenId, currentTableTabId, JSON.stringify(presetFilters)]);
// select 옵션 초기 로드 (한 번만 실행, 이후 유지)
// select 옵션 로드 (데이터 변경 시 빈 옵션 재조회)
useEffect(() => {
if (!currentTable?.getColumnUniqueValues || activeFilters.length === 0) {
return;
@ -450,26 +450,37 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
return;
}
const newOptions: Record<string, Array<{ label: string; value: string }>> = { ...selectOptions };
const loadedOptions: Record<string, Array<{ label: string; value: string }>> = {};
let hasNewOptions = false;
for (const filter of selectFilters) {
// 이미 로드된 옵션이 있으면 스킵 (초기값 유지)
if (newOptions[filter.columnName] && newOptions[filter.columnName].length > 0) {
continue;
}
try {
const options = await currentTable.getColumnUniqueValues(filter.columnName);
newOptions[filter.columnName] = options;
if (options && options.length > 0) {
loadedOptions[filter.columnName] = options;
hasNewOptions = true;
}
} catch (error) {
console.error("❌ [TableSearchWidget] select 옵션 로드 실패:", filter.columnName, error);
}
}
setSelectOptions(newOptions);
if (hasNewOptions) {
setSelectOptions((prev) => {
// 이미 로드된 옵션은 유지, 새로 로드된 옵션만 병합
const merged = { ...prev };
for (const [key, value] of Object.entries(loadedOptions)) {
if (!merged[key] || merged[key].length === 0) {
merged[key] = value;
}
}
return merged;
});
}
};
loadSelectOptions();
}, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues]); // dataCount 제거, tableName으로 변경
}, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues, currentTable?.dataCount]);
// 높이 변화 감지 및 알림 (실제 화면에서만)
useEffect(() => {
@ -722,7 +733,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: `${width}px` }} align="start">
<PopoverContent className="w-auto p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<div className="max-h-60 overflow-auto">
{uniqueOptions.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs"> </div>
@ -739,7 +750,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
onCheckedChange={(checked) => handleMultiSelectChange(option.value, checked as boolean)}
onClick={(e) => e.stopPropagation()}
/>
<span className="text-xs sm:text-sm">{option.label}</span>
<span className="truncate text-xs sm:text-sm">{option.label}</span>
</div>
))}
</div>