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

@@ -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"
variant: {
type: "enum",
values: ["default", "outlined", "filled"],
default: "default",
},
size: {
type: "enum",
values: ["sm", "md", "lg"],
default: "md"
size: {
type: "enum",
values: ["sm", "md", "lg"],
default: "md",
},
};

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,20 +6,24 @@ import { ComponentConfig } from "@/types/component";
* ImageDisplay 컴포넌트 설정 타입
*/
export interface ImageDisplayConfig extends ComponentConfig {
// file 관련 설정
// 이미지 관련 설정
imageUrl?: string;
altText?: string;
objectFit?: "contain" | "cover" | "fill" | "none" | "scale-down";
borderRadius?: number;
showBorder?: boolean;
backgroundColor?: string;
placeholder?: string;
// 공통 설정
disabled?: boolean;
required?: boolean;
readonly?: boolean;
placeholder?: string;
helperText?: string;
// 스타일 관련
variant?: "default" | "outlined" | "filled";
size?: "sm" | "md" | "lg";
// 이벤트 관련
onChange?: (value: any) => void;
onFocus?: () => void;
@@ -37,7 +41,7 @@ export interface ImageDisplayProps {
config?: ImageDisplayConfig;
className?: string;
style?: React.CSSProperties;
// 이벤트 핸들러
onChange?: (value: any) => void;
onFocus?: () => void;

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);
// 이미 같은 objid의 파일이 로드되어 있으면 스킵
const alreadyLoaded = uploadedFiles.some(f => String(f.objid) === objidStr);
if (alreadyLoaded) {
return;
}
// 🔑 실제 파일 정보 조회 (previewUrl 제거 - apiClient blob 다운로드 방식으로 통일)
(async () => {
try {
const fileInfoResponse = await getFileInfoByObjid(objidStr);
if (!imageObjidFromFormData) return;
const rawValue = String(imageObjidFromFormData);
// 콤마 구분 다중 objid 또는 단일 objid 모두 처리
const objids = rawValue.split(',').map(s => s.trim()).filter(s => /^\d+$/.test(s));
if (objids.length === 0) return;
// 모든 objid가 이미 로드되어 있으면 스킵
const allLoaded = objids.every(id => uploadedFiles.some(f => String(f.objid) === id));
if (allLoaded) return;
(async () => {
try {
const loadedFiles: FileInfo[] = [];
for (const objid of objids) {
// 이미 로드된 파일은 스킵
if (uploadedFiles.some(f => String(f.objid) === objid)) continue;
const fileInfoResponse = await getFileInfoByObjid(objid);
if (fileInfoResponse.success && fileInfoResponse.data) {
const { realFileName, fileSize, fileExt, regdate, isRepresentative } = fileInfoResponse.data;
const fileInfo = {
objid: objidStr,
realFileName: realFileName,
fileExt: fileExt,
fileSize: fileSize,
filePath: getFilePreviewUrl(objidStr),
regdate: regdate,
loadedFiles.push({
objid,
realFileName,
fileExt,
fileSize,
filePath: getFilePreviewUrl(objid),
regdate,
isImage: true,
isRepresentative: isRepresentative,
};
setUploadedFiles([fileInfo]);
// representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨
isRepresentative,
} as FileInfo);
} else {
// 파일 정보 조회 실패 시 최소 정보로 추가
console.warn("🖼️ [FileUploadComponent] 파일 정보 조회 실패, 최소 정보 사용");
const minimalFileInfo = {
objid: objidStr,
realFileName: `image_${objidStr}.jpg`,
loadedFiles.push({
objid,
realFileName: `file_${objid}`,
fileExt: '.jpg',
fileSize: 0,
filePath: getFilePreviewUrl(objidStr),
filePath: getFilePreviewUrl(objid),
regdate: new Date().toISOString(),
isImage: true,
};
setUploadedFiles([minimalFileInfo]);
// representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨
} as FileInfo);
}
} catch (error) {
console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error);
}
})();
}
}, [imageObjidFromFormData, columnName, component.id]); // 🆕 formData 대신 특정 컬럼 값만 의존
if (loadedFiles.length > 0) {
setUploadedFiles(loadedFiles);
filesLoadedFromObjidRef.current = true;
}
} catch (error) {
console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error);
}
})();
}, [imageObjidFromFormData, columnName, component.id]);
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
// 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
@@ -365,6 +373,10 @@ const FileUploadComponent: React.FC<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>