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:
@@ -1328,9 +1328,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
|
||||
{/* 확인 다이얼로그 */}
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialogContent className="z-[99999]">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{getConfirmMessage()}</AlertDialogDescription>
|
||||
|
||||
@@ -24,29 +24,33 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component.config,
|
||||
} as ImageDisplayConfig;
|
||||
|
||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||
const objectFit = componentConfig.objectFit || "contain";
|
||||
const altText = componentConfig.altText || "이미지";
|
||||
const borderRadius = componentConfig.borderRadius ?? 8;
|
||||
const showBorder = componentConfig.showBorder ?? true;
|
||||
const backgroundColor = componentConfig.backgroundColor || "#f9fafb";
|
||||
const placeholder = componentConfig.placeholder || "이미지 없음";
|
||||
|
||||
const imageSrc = component.value || componentConfig.imageUrl || "";
|
||||
|
||||
const componentStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
if (isDesignMode) {
|
||||
componentStyle.border = "1px dashed #cbd5e1";
|
||||
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
||||
}
|
||||
|
||||
// 이벤트 핸들러
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
@@ -88,7 +92,9 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
{(component.required || componentConfig.required) && (
|
||||
<span style={{ color: "#ef4444" }}>*</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
@@ -96,43 +102,53 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "8px",
|
||||
border: showBorder ? "1px solid #d1d5db" : "none",
|
||||
borderRadius: `${borderRadius}px`,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#f9fafb",
|
||||
backgroundColor,
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
boxShadow: showBorder ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : "none",
|
||||
opacity: componentConfig.disabled ? 0.5 : 1,
|
||||
cursor: componentConfig.disabled ? "not-allowed" : "default",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "#f97316";
|
||||
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)";
|
||||
if (!componentConfig.disabled) {
|
||||
if (showBorder) {
|
||||
e.currentTarget.style.borderColor = "#f97316";
|
||||
}
|
||||
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "#d1d5db";
|
||||
e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
||||
if (showBorder) {
|
||||
e.currentTarget.style.borderColor = "#d1d5db";
|
||||
}
|
||||
e.currentTarget.style.boxShadow = showBorder
|
||||
? "0 1px 2px 0 rgba(0, 0, 0, 0.05)"
|
||||
: "none";
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
{component.value || componentConfig.imageUrl ? (
|
||||
{imageSrc ? (
|
||||
<img
|
||||
src={component.value || componentConfig.imageUrl}
|
||||
alt={componentConfig.altText || "이미지"}
|
||||
src={imageSrc}
|
||||
alt={altText}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
objectFit: componentConfig.objectFit || "contain",
|
||||
objectFit,
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
if (e.target?.parentElement) {
|
||||
e.target.parentElement.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px; color: #6b7280; font-size: 14px;">
|
||||
<div style="font-size: 24px;">🖼️</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>
|
||||
<div>이미지 로드 실패</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -150,8 +166,22 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "32px" }}>🖼️</div>
|
||||
<div>이미지 없음</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
<div>{placeholder}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -161,7 +191,6 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
||||
|
||||
/**
|
||||
* ImageDisplay 래퍼 컴포넌트
|
||||
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
||||
*/
|
||||
export const ImageDisplayWrapper: React.FC<ImageDisplayComponentProps> = (props) => {
|
||||
return <ImageDisplayComponent {...props} />;
|
||||
|
||||
@@ -9,63 +9,166 @@ import { ImageDisplayConfig } from "./types";
|
||||
|
||||
export interface ImageDisplayConfigPanelProps {
|
||||
config: ImageDisplayConfig;
|
||||
onChange: (config: Partial<ImageDisplayConfig>) => void;
|
||||
onChange?: (config: Partial<ImageDisplayConfig>) => void;
|
||||
onConfigChange?: (config: Partial<ImageDisplayConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ImageDisplay 설정 패널
|
||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const ImageDisplayConfigPanel: React.FC<ImageDisplayConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
onConfigChange,
|
||||
}) => {
|
||||
const handleChange = (key: keyof ImageDisplayConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
const update = { ...config, [key]: value };
|
||||
onChange?.(update);
|
||||
onConfigChange?.(update);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">
|
||||
image-display 설정
|
||||
<div className="text-sm font-medium">이미지 표시 설정</div>
|
||||
|
||||
{/* 이미지 URL */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="imageUrl" className="text-xs">
|
||||
기본 이미지 URL
|
||||
</Label>
|
||||
<Input
|
||||
id="imageUrl"
|
||||
value={config.imageUrl || ""}
|
||||
onChange={(e) => handleChange("imageUrl", e.target.value)}
|
||||
placeholder="https://..."
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
데이터 바인딩 값이 없을 때 표시할 기본 이미지
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* file 관련 설정 */}
|
||||
{/* 대체 텍스트 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder">플레이스홀더</Label>
|
||||
<Label htmlFor="altText" className="text-xs">
|
||||
대체 텍스트 (alt)
|
||||
</Label>
|
||||
<Input
|
||||
id="altText"
|
||||
value={config.altText || ""}
|
||||
onChange={(e) => handleChange("altText", e.target.value)}
|
||||
placeholder="이미지 설명"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 이미지 맞춤 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="objectFit" className="text-xs">
|
||||
이미지 맞춤 (Object Fit)
|
||||
</Label>
|
||||
<Select
|
||||
value={config.objectFit || "contain"}
|
||||
onValueChange={(value) => handleChange("objectFit", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="contain">Contain (비율 유지, 전체 표시)</SelectItem>
|
||||
<SelectItem value="cover">Cover (비율 유지, 영역 채움)</SelectItem>
|
||||
<SelectItem value="fill">Fill (영역에 맞춤)</SelectItem>
|
||||
<SelectItem value="none">None (원본 크기)</SelectItem>
|
||||
<SelectItem value="scale-down">Scale Down (축소만)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테두리 둥글기 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderRadius" className="text-xs">
|
||||
테두리 둥글기 (px)
|
||||
</Label>
|
||||
<Input
|
||||
id="borderRadius"
|
||||
type="number"
|
||||
min="0"
|
||||
max="50"
|
||||
value={config.borderRadius ?? 8}
|
||||
onChange={(e) => handleChange("borderRadius", parseInt(e.target.value) || 0)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 배경 색상 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backgroundColor" className="text-xs">
|
||||
배경 색상
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={config.backgroundColor || "#f9fafb"}
|
||||
onChange={(e) => handleChange("backgroundColor", e.target.value)}
|
||||
className="h-8 w-8 cursor-pointer rounded border"
|
||||
/>
|
||||
<Input
|
||||
id="backgroundColor"
|
||||
value={config.backgroundColor || "#f9fafb"}
|
||||
onChange={(e) => handleChange("backgroundColor", e.target.value)}
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 플레이스홀더 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder" className="text-xs">
|
||||
이미지 없을 때 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={config.placeholder || ""}
|
||||
onChange={(e) => handleChange("placeholder", e.target.value)}
|
||||
placeholder="이미지 없음"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공통 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disabled">비활성화</Label>
|
||||
{/* 테두리 표시 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="disabled"
|
||||
checked={config.disabled || false}
|
||||
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
||||
id="showBorder"
|
||||
checked={config.showBorder ?? true}
|
||||
onCheckedChange={(checked) => handleChange("showBorder", checked)}
|
||||
/>
|
||||
<Label htmlFor="showBorder" className="text-xs cursor-pointer">
|
||||
테두리 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="required">필수 입력</Label>
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={config.required || false}
|
||||
onCheckedChange={(checked) => handleChange("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="readonly">읽기 전용</Label>
|
||||
{/* 읽기 전용 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="readonly"
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
<Label htmlFor="readonly" className="text-xs cursor-pointer">
|
||||
읽기 전용
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 필수 입력 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={config.required || false}
|
||||
onCheckedChange={(checked) => handleChange("required", checked)}
|
||||
/>
|
||||
<Label htmlFor="required" className="text-xs cursor-pointer">
|
||||
필수 입력
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,9 +6,14 @@ import { ImageDisplayConfig } from "./types";
|
||||
* ImageDisplay 컴포넌트 기본 설정
|
||||
*/
|
||||
export const ImageDisplayDefaultConfig: ImageDisplayConfig = {
|
||||
placeholder: "입력하세요",
|
||||
|
||||
// 공통 기본값
|
||||
imageUrl: "",
|
||||
altText: "이미지",
|
||||
objectFit: "contain",
|
||||
borderRadius: 8,
|
||||
showBorder: true,
|
||||
backgroundColor: "#f9fafb",
|
||||
placeholder: "이미지 없음",
|
||||
|
||||
disabled: false,
|
||||
required: false,
|
||||
readonly: false,
|
||||
@@ -18,23 +23,31 @@ export const ImageDisplayDefaultConfig: ImageDisplayConfig = {
|
||||
|
||||
/**
|
||||
* ImageDisplay 컴포넌트 설정 스키마
|
||||
* 유효성 검사 및 타입 체크에 사용
|
||||
*/
|
||||
export const ImageDisplayConfigSchema = {
|
||||
placeholder: { type: "string", default: "" },
|
||||
|
||||
// 공통 스키마
|
||||
imageUrl: { type: "string", default: "" },
|
||||
altText: { type: "string", default: "이미지" },
|
||||
objectFit: {
|
||||
type: "enum",
|
||||
values: ["contain", "cover", "fill", "none", "scale-down"],
|
||||
default: "contain",
|
||||
},
|
||||
borderRadius: { type: "number", default: 8 },
|
||||
showBorder: { type: "boolean", default: true },
|
||||
backgroundColor: { type: "string", default: "#f9fafb" },
|
||||
placeholder: { type: "string", default: "이미지 없음" },
|
||||
|
||||
disabled: { type: "boolean", default: false },
|
||||
required: { type: "boolean", default: false },
|
||||
readonly: { type: "boolean", default: false },
|
||||
variant: {
|
||||
type: "enum",
|
||||
values: ["default", "outlined", "filled"],
|
||||
default: "default"
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -21,7 +21,13 @@ export const ImageDisplayDefinition = createComponentDefinition({
|
||||
webType: "file",
|
||||
component: ImageDisplayWrapper,
|
||||
defaultConfig: {
|
||||
placeholder: "입력하세요",
|
||||
imageUrl: "",
|
||||
altText: "이미지",
|
||||
objectFit: "contain",
|
||||
borderRadius: 8,
|
||||
showBorder: true,
|
||||
backgroundColor: "#f9fafb",
|
||||
placeholder: "이미지 없음",
|
||||
},
|
||||
defaultSize: { width: 200, height: 200 },
|
||||
configPanel: ImageDisplayConfigPanel,
|
||||
|
||||
@@ -6,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;
|
||||
|
||||
@@ -28,6 +28,7 @@ import { apiClient } from "@/lib/api/client";
|
||||
import { allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule";
|
||||
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||
|
||||
import {
|
||||
UniversalFormModalComponentProps,
|
||||
@@ -1835,11 +1836,11 @@ export function UniversalFormModalComponent({
|
||||
|
||||
case "date":
|
||||
return (
|
||||
<Input
|
||||
<FormDatePicker
|
||||
id={fieldKey}
|
||||
type="date"
|
||||
value={value || ""}
|
||||
onChange={(e) => onChangeHandler(e.target.value)}
|
||||
onChange={onChangeHandler}
|
||||
placeholder={field.placeholder || "날짜를 선택하세요"}
|
||||
disabled={isDisabled}
|
||||
readOnly={field.readOnly}
|
||||
/>
|
||||
@@ -1847,13 +1848,14 @@ export function UniversalFormModalComponent({
|
||||
|
||||
case "datetime":
|
||||
return (
|
||||
<Input
|
||||
<FormDatePicker
|
||||
id={fieldKey}
|
||||
type="datetime-local"
|
||||
value={value || ""}
|
||||
onChange={(e) => onChangeHandler(e.target.value)}
|
||||
onChange={onChangeHandler}
|
||||
placeholder={field.placeholder || "날짜/시간을 선택하세요"}
|
||||
disabled={isDisabled}
|
||||
readOnly={field.readOnly}
|
||||
includeTime
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -1481,9 +1481,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
|
||||
{/* 확인 다이얼로그 */}
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialogContent className="z-[99999]">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{getConfirmMessage()}</AlertDialogDescription>
|
||||
|
||||
@@ -247,14 +247,12 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex h-[75vh] flex-col space-y-3">
|
||||
{/* 파일 업로드 영역 - 높이 축소 */}
|
||||
{!isDesignMode && (
|
||||
{/* 파일 업로드 영역 - readonly/disabled이면 숨김 */}
|
||||
{!isDesignMode && !config.readonly && !config.disabled && (
|
||||
<div
|
||||
className={`cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors ${dragOver ? "border-blue-400 bg-blue-50" : "border-gray-300"} ${config.disabled ? "cursor-not-allowed opacity-50" : "hover:border-gray-400"} ${uploading ? "opacity-75" : ""} `}
|
||||
className={`cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors ${dragOver ? "border-blue-400 bg-blue-50" : "border-gray-300"} hover:border-gray-400 ${uploading ? "opacity-75" : ""} `}
|
||||
onClick={() => {
|
||||
if (!config.disabled && !isDesignMode) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -267,7 +265,6 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||
accept={config.accept}
|
||||
onChange={handleFileInputChange}
|
||||
className="hidden"
|
||||
disabled={config.disabled}
|
||||
/>
|
||||
|
||||
{uploading ? (
|
||||
@@ -286,8 +283,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||
|
||||
{/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
{/* 좌측: 이미지 미리보기 (확대/축소 가능) */}
|
||||
<div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border border-gray-200 bg-gray-900">
|
||||
{/* 좌측: 이미지 미리보기 (확대/축소 가능) - showPreview가 false면 숨김 */}
|
||||
{(config.showPreview !== false) && <div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border border-gray-200 bg-gray-900">
|
||||
{/* 확대/축소 컨트롤 */}
|
||||
{selectedFile && previewImageUrl && (
|
||||
<div className="absolute top-3 left-3 z-10 flex items-center gap-1 rounded-lg bg-black/60 p-1">
|
||||
@@ -369,10 +366,10 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||
{selectedFile.realFileName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* 우측: 파일 목록 (고정 너비) */}
|
||||
<div className="flex w-[400px] shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200">
|
||||
{/* 우측: 파일 목록 - showFileList가 false면 숨김, showPreview가 false면 전체 너비 */}
|
||||
{(config.showFileList !== false) && <div className={`flex shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200 ${config.showPreview !== false ? "w-[400px]" : "flex-1"}`}>
|
||||
<div className="border-b border-gray-200 bg-gray-50 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-700">업로드된 파일</h3>
|
||||
@@ -404,7 +401,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatFileSize(file.fileSize)} • {file.fileExt.toUpperCase()}
|
||||
{config.showFileSize !== false && <>{formatFileSize(file.fileSize)} • </>}{file.fileExt.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
@@ -434,19 +431,21 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFileDownload(file);
|
||||
}}
|
||||
title="다운로드"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</Button>
|
||||
{!isDesignMode && (
|
||||
{config.allowDownload !== false && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFileDownload(file);
|
||||
}}
|
||||
title="다운로드"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
{!isDesignMode && config.allowDelete !== false && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -476,7 +475,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -487,8 +486,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||
file={viewerFile}
|
||||
isOpen={isViewerOpen}
|
||||
onClose={handleViewerClose}
|
||||
onDownload={onFileDownload}
|
||||
onDelete={!isDesignMode ? onFileDelete : undefined}
|
||||
onDownload={config.allowDownload !== false ? onFileDownload : undefined}
|
||||
onDelete={!isDesignMode && config.allowDelete !== false ? onFileDelete : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -105,6 +105,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
const [forceUpdate, setForceUpdate] = useState(0);
|
||||
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
// objid 기반으로 파일이 로드되었는지 추적 (다른 이펙트가 덮어쓰지 않도록 방지)
|
||||
const filesLoadedFromObjidRef = useRef(false);
|
||||
|
||||
// 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리
|
||||
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
|
||||
@@ -150,6 +152,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
if (isRecordMode || !recordId) {
|
||||
setUploadedFiles([]);
|
||||
setRepresentativeImageUrl(null);
|
||||
filesLoadedFromObjidRef.current = false;
|
||||
}
|
||||
} else if (prevIsRecordModeRef.current === null) {
|
||||
// 초기 마운트 시 모드 저장
|
||||
@@ -191,63 +194,68 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
}, [component.id, getUniqueKey, recordId, isRecordMode]); // 레코드별 고유 키 변경 시 재실행
|
||||
|
||||
// 🔑 수정 모드: formData[columnName]에 저장된 objid로 이미지 로드
|
||||
// 🆕 formData 전체가 아닌 특정 컬럼 값만 의존하도록 수정 (다른 컴포넌트 영향 방지)
|
||||
// 콤마로 구분된 다중 objid도 처리 (예: "123,456")
|
||||
const imageObjidFromFormData = formData?.[columnName];
|
||||
|
||||
useEffect(() => {
|
||||
// 이미지 objid가 있고, 숫자 문자열인 경우에만 처리
|
||||
if (imageObjidFromFormData && /^\d+$/.test(String(imageObjidFromFormData))) {
|
||||
const objidStr = String(imageObjidFromFormData);
|
||||
|
||||
// 이미 같은 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}
|
||||
/>
|
||||
|
||||
{/* 파일 관리 모달 */}
|
||||
|
||||
@@ -2172,7 +2172,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
const handleRowClick = (row: any, index: number, e: React.MouseEvent) => {
|
||||
// 체크박스 클릭은 무시 (이미 handleRowSelection에서 처리됨)
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('input[type="checkbox"]')) {
|
||||
if (target.closest('input[type="checkbox"]') || target.closest('button[role="checkbox"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2198,35 +2198,32 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택)
|
||||
// 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택/해제 토글)
|
||||
const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setFocusedCell({ rowIndex, colIndex });
|
||||
// 테이블 컨테이너에 포커스 설정 (키보드 이벤트 수신용)
|
||||
tableContainerRef.current?.focus();
|
||||
|
||||
// 🆕 분할 패널 내에서 셀 클릭 시에도 해당 행 선택 처리
|
||||
// filteredData에서 해당 행의 데이터 가져오기
|
||||
const row = filteredData[rowIndex];
|
||||
if (!row) return;
|
||||
|
||||
// 체크박스 컬럼은 Checkbox의 onCheckedChange에서 이미 처리되므로 스킵
|
||||
const column = visibleColumns[colIndex];
|
||||
if (column?.columnName === "__checkbox__") return;
|
||||
|
||||
const rowKey = getRowKey(row, rowIndex);
|
||||
const isCurrentlySelected = selectedRows.has(rowKey);
|
||||
|
||||
// 분할 패널 컨텍스트가 있고, 좌측 화면인 경우에만 행 선택 및 데이터 전달
|
||||
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
|
||||
|
||||
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
||||
// 이미 선택된 행과 다른 행을 클릭한 경우에만 처리
|
||||
// 분할 패널 좌측: 단일 행 선택 모드
|
||||
if (!isCurrentlySelected) {
|
||||
// 기존 선택 해제하고 새 행 선택
|
||||
setSelectedRows(new Set([rowKey]));
|
||||
setIsAllSelected(false);
|
||||
|
||||
// 분할 패널 컨텍스트에 데이터 저장
|
||||
splitPanelContext.setSelectedLeftData(row);
|
||||
|
||||
// onSelectedRowsChange 콜백 호출
|
||||
if (onSelectedRowsChange) {
|
||||
onSelectedRowsChange([rowKey], [row], sortColumn || undefined, sortDirection);
|
||||
}
|
||||
@@ -2234,6 +2231,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
onFormDataChange({ selectedRows: [rowKey], selectedRowsData: [row] });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 일반 모드: 행 선택/해제 토글
|
||||
handleRowSelection(rowKey, !isCurrentlySelected);
|
||||
|
||||
if (splitPanelContext && effectiveSplitPosition === "left") {
|
||||
if (!isCurrentlySelected) {
|
||||
splitPanelContext.setSelectedLeftData(row);
|
||||
} else {
|
||||
splitPanelContext.setSelectedLeftData(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6309,6 +6317,21 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// 날짜 타입: 캘린더 피커
|
||||
const isDateType = colMeta?.inputType === "date" || colMeta?.inputType === "datetime";
|
||||
if (isDateType) {
|
||||
const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker");
|
||||
return (
|
||||
<InlineCellDatePicker
|
||||
value={editingValue}
|
||||
onChange={setEditingValue}
|
||||
onSave={saveEditing}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
inputRef={editInputRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 입력 필드
|
||||
return (
|
||||
<input
|
||||
|
||||
@@ -437,7 +437,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentTable?.tableName, filterMode, screenId, currentTableTabId, JSON.stringify(presetFilters)]);
|
||||
|
||||
// select 옵션 초기 로드 (한 번만 실행, 이후 유지)
|
||||
// select 옵션 로드 (데이터 변경 시 빈 옵션 재조회)
|
||||
useEffect(() => {
|
||||
if (!currentTable?.getColumnUniqueValues || activeFilters.length === 0) {
|
||||
return;
|
||||
@@ -450,26 +450,37 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||
return;
|
||||
}
|
||||
|
||||
const newOptions: Record<string, Array<{ label: string; value: string }>> = { ...selectOptions };
|
||||
const loadedOptions: Record<string, Array<{ label: string; value: string }>> = {};
|
||||
let hasNewOptions = false;
|
||||
|
||||
for (const filter of selectFilters) {
|
||||
// 이미 로드된 옵션이 있으면 스킵 (초기값 유지)
|
||||
if (newOptions[filter.columnName] && newOptions[filter.columnName].length > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const options = await currentTable.getColumnUniqueValues(filter.columnName);
|
||||
newOptions[filter.columnName] = options;
|
||||
if (options && options.length > 0) {
|
||||
loadedOptions[filter.columnName] = options;
|
||||
hasNewOptions = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ [TableSearchWidget] select 옵션 로드 실패:", filter.columnName, error);
|
||||
}
|
||||
}
|
||||
setSelectOptions(newOptions);
|
||||
|
||||
if (hasNewOptions) {
|
||||
setSelectOptions((prev) => {
|
||||
// 이미 로드된 옵션은 유지, 새로 로드된 옵션만 병합
|
||||
const merged = { ...prev };
|
||||
for (const [key, value] of Object.entries(loadedOptions)) {
|
||||
if (!merged[key] || merged[key].length === 0) {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
loadSelectOptions();
|
||||
}, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues]); // dataCount 제거, tableName으로 변경
|
||||
}, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues, currentTable?.dataCount]);
|
||||
|
||||
// 높이 변화 감지 및 알림 (실제 화면에서만)
|
||||
useEffect(() => {
|
||||
@@ -722,7 +733,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: `${width}px` }} align="start">
|
||||
<PopoverContent className="w-auto p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<div className="max-h-60 overflow-auto">
|
||||
{uniqueOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground px-3 py-2 text-xs">옵션 없음</div>
|
||||
@@ -739,7 +750,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||
onCheckedChange={(checked) => handleMultiSelectChange(option.value, checked as boolean)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="text-xs sm:text-sm">{option.label}</span>
|
||||
<span className="truncate text-xs sm:text-sm">{option.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user