Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node

; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
This commit is contained in:
DDD1542 2026-02-24 09:30:02 +09:00
commit 5ec689101e
10 changed files with 988 additions and 912 deletions

View File

@ -5177,8 +5177,18 @@ export class ScreenManagementService {
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다."); throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
} }
// 화면의 기본 테이블 업데이트 (테이블이 선택된 경우)
const mainTableName = layoutData.mainTableName;
if (mainTableName) {
await query(
`UPDATE screen_definitions SET table_name = $1, updated_date = NOW() WHERE screen_id = $2`,
[mainTableName, screenId],
);
console.log(`✅ [saveLayoutV2] 화면 기본 테이블 업데이트: ${mainTableName}`);
}
// 저장할 layout_data에서 레이어 메타 정보 제거 (순수 레이아웃만 저장) // 저장할 layout_data에서 레이어 메타 정보 제거 (순수 레이아웃만 저장)
const { layerId: _lid, layerName: _ln, conditionConfig: _cc, ...pureLayoutData } = layoutData; const { layerId: _lid, layerName: _ln, conditionConfig: _cc, mainTableName: _mtn, ...pureLayoutData } = layoutData;
const dataToSave = { const dataToSave = {
version: "2.0", version: "2.0",
...pureLayoutData, ...pureLayoutData,

View File

@ -2062,6 +2062,7 @@ export default function ScreenDesigner({
await screenApi.saveLayoutV2(selectedScreen.screenId, { await screenApi.saveLayoutV2(selectedScreen.screenId, {
...v2Layout, ...v2Layout,
layerId: currentLayerId, layerId: currentLayerId,
mainTableName: currentMainTableName, // 화면의 기본 테이블 (DB 업데이트용)
}); });
} else { } else {
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);

View File

@ -114,8 +114,7 @@ export function ComponentsPanel({
"image-display", // → v2-media (image) "image-display", // → v2-media (image)
// 공통코드관리로 통합 예정 // 공통코드관리로 통합 예정
"category-manager", // → 공통코드관리 기능으로 통합 예정 "category-manager", // → 공통코드관리 기능으로 통합 예정
// 분할 패널 정리 (split-panel-layout v1 유지) // 분할 패널 정리
"split-panel-layout2", // → split-panel-layout로 통합
"screen-split-panel", // 화면 임베딩 방식은 사용하지 않음 "screen-split-panel", // 화면 임베딩 방식은 사용하지 않음
// 미완성/미사용 컴포넌트 (기존 화면 호환성 유지, 새 추가만 막음) // 미완성/미사용 컴포넌트 (기존 화면 호환성 유지, 새 추가만 막음)
"accordion-basic", // 아코디언 컴포넌트 "accordion-basic", // 아코디언 컴포넌트

View File

@ -44,6 +44,11 @@ interface EntityJoinTable {
tableName: string; tableName: string;
currentDisplayColumn: string; currentDisplayColumn: string;
availableColumns: EntityJoinColumn[]; availableColumns: EntityJoinColumn[];
// 같은 테이블이 여러 FK로 조인될 수 있으므로 소스 컬럼으로 구분
joinConfig?: {
sourceColumn: string;
[key: string]: unknown;
};
} }
interface TablesPanelProps { interface TablesPanelProps {
@ -414,7 +419,11 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
</Badge> </Badge>
</div> </div>
{entityJoinTables.map((joinTable) => { {entityJoinTables.map((joinTable, idx) => {
// 같은 테이블이 여러 FK로 조인될 수 있으므로 sourceColumn으로 고유 키 생성
const uniqueKey = joinTable.joinConfig?.sourceColumn
? `entity-join-${joinTable.tableName}-${joinTable.joinConfig.sourceColumn}`
: `entity-join-${joinTable.tableName}-${idx}`;
const isExpanded = expandedJoinTables.has(joinTable.tableName); const isExpanded = expandedJoinTables.has(joinTable.tableName);
// 검색어로 필터링 // 검색어로 필터링
const filteredColumns = searchTerm const filteredColumns = searchTerm
@ -431,8 +440,7 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
} }
return ( return (
// 엔티티 조인 테이블에 고유 접두사 추가 (메인 테이블과 키 중복 방지) <div key={uniqueKey} className="space-y-1">
<div key={`entity-join-${joinTable.tableName}`} className="space-y-1">
{/* 조인 테이블 헤더 */} {/* 조인 테이블 헤더 */}
<div <div
className="flex cursor-pointer items-center justify-between rounded-md bg-cyan-50 p-2 hover:bg-cyan-100" className="flex cursor-pointer items-center justify-between rounded-md bg-cyan-50 p-2 hover:bg-cyan-100"

View File

@ -135,8 +135,27 @@ export function TabsWidget({
const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({}); const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({});
const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({}); const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({});
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({}); const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
// 탭별 화면 정보 (screenId, tableName) 저장 // 탭별 화면 정보 (screenId, tableName) - 인라인 컴포넌트의 테이블 설정에서 추출
const [screenInfoMap, setScreenInfoMap] = useState<Record<string, { id: number; tableName?: string }>>({}); const screenInfoMap = React.useMemo(() => {
const map: Record<string, { id?: number; tableName?: string }> = {};
for (const tab of tabs as ExtendedTabItem[]) {
const inlineComponents = tab.components || [];
if (inlineComponents.length > 0) {
// 인라인 컴포넌트에서 테이블 컴포넌트의 selectedTable 추출
const tableComp = inlineComponents.find(
(c) => c.componentType === "v2-table-list" || c.componentType === "table-list",
);
const selectedTable = tableComp?.componentConfig?.selectedTable;
if (selectedTable || tab.screenId) {
map[tab.id] = {
id: tab.screenId,
tableName: selectedTable,
};
}
}
}
return map;
}, [tabs]);
// 컴포넌트 탭 목록 변경 시 동기화 // 컴포넌트 탭 목록 변경 시 동기화
useEffect(() => { useEffect(() => {
@ -157,21 +176,10 @@ export function TabsWidget({
) { ) {
setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: true })); setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: true }));
try { try {
// 레이아웃과 화면 정보를 병렬로 로드 const layoutData = await screenApi.getLayout(extTab.screenId);
const [layoutData, screenDef] = await Promise.all([
screenApi.getLayout(extTab.screenId),
screenApi.getScreen(extTab.screenId),
]);
if (layoutData && layoutData.components) { if (layoutData && layoutData.components) {
setScreenLayouts((prev) => ({ ...prev, [tab.id]: layoutData.components })); setScreenLayouts((prev) => ({ ...prev, [tab.id]: layoutData.components }));
} }
// 탭의 화면 정보 저장 (tableName 포함)
if (screenDef) {
setScreenInfoMap((prev) => ({
...prev,
[tab.id]: { id: extTab.screenId!, tableName: screenDef.tableName },
}));
}
} catch (error) { } catch (error) {
console.error(`탭 "${tab.label}" 화면 로드 실패:`, error); console.error(`탭 "${tab.label}" 화면 로드 실패:`, error);
setScreenErrors((prev) => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." })); setScreenErrors((prev) => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." }));
@ -185,31 +193,6 @@ export function TabsWidget({
loadScreenLayouts(); loadScreenLayouts();
}, [visibleTabs, screenLayouts, screenLoadingStates]); }, [visibleTabs, screenLayouts, screenLoadingStates]);
// screenInfoMap이 없는 탭의 화면 정보 보충 로드
// screenId가 있지만 screenInfoMap에 아직 없는 탭의 화면 정보를 로드
useEffect(() => {
const loadMissingScreenInfo = async () => {
for (const tab of visibleTabs) {
const extTab = tab as ExtendedTabItem;
// screenId가 있고 screenInfoMap에 아직 없는 경우 로드
if (extTab.screenId && !screenInfoMap[tab.id]) {
try {
const screenDef = await screenApi.getScreen(extTab.screenId);
if (screenDef) {
setScreenInfoMap((prev) => ({
...prev,
[tab.id]: { id: extTab.screenId!, tableName: screenDef.tableName },
}));
}
} catch (error) {
console.error(`탭 "${tab.label}" 화면 정보 로드 실패:`, error);
}
}
}
};
loadMissingScreenInfo();
}, [visibleTabs, screenInfoMap]);
// 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트 // 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트
useEffect(() => { useEffect(() => {
if (persistSelection && typeof window !== "undefined") { if (persistSelection && typeof window !== "undefined") {

View File

@ -25,8 +25,22 @@ import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { V2MediaProps } from "@/types/v2-components"; import { V2MediaProps } from "@/types/v2-components";
import { import {
Upload, X, File, Image as ImageIcon, Video, Music, Eye, Download, Trash2, Plus, Upload,
FileText, Archive, Presentation, FileImage, FileVideo, FileAudio X,
File,
Image as ImageIcon,
Video,
Music,
Eye,
Download,
Trash2,
Plus,
FileText,
Archive,
Presentation,
FileImage,
FileVideo,
FileAudio,
} from "lucide-react"; } from "lucide-react";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { toast } from "sonner"; import { toast } from "sonner";
@ -77,8 +91,7 @@ const getFileIcon = (extension: string) => {
/** /**
* V2 ( ) * V2 ( )
*/ */
export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>( export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>((props, ref) => {
(props, ref) => {
const { const {
id, id,
label, label,
@ -121,11 +134,11 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// 레코드 모드 판단 // 레코드 모드 판단
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_')); const isRecordMode = !!(formData?.id && !String(formData.id).startsWith("temp_"));
const recordTableName = formData?.tableName || tableName; const recordTableName = formData?.tableName || tableName;
const recordId = formData?.id; const recordId = formData?.id;
// 🔑 columnName 우선 사용 (실제 DB 컬럼명), 없으면 id, 최후에 attachments // 🔑 columnName 우선 사용 (실제 DB 컬럼명), 없으면 id, 최후에 attachments
const effectiveColumnName = columnName || id || 'attachments'; const effectiveColumnName = columnName || id || "attachments";
// 레코드용 targetObjid 생성 // 레코드용 targetObjid 생성
const getRecordTargetObjid = useCallback(() => { const getRecordTargetObjid = useCallback(() => {
@ -269,7 +282,20 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
console.error("파일 조회 오류:", error); console.error("파일 조회 오류:", error);
} }
return false; return false;
}, [id, tableName, columnName, formData?.screenId, formData?.tableName, formData?.id, getUniqueKey, recordId, isRecordMode, recordTableName, effectiveColumnName, isDesignMode]); }, [
id,
tableName,
columnName,
formData?.screenId,
formData?.tableName,
formData?.id,
getUniqueKey,
recordId,
isRecordMode,
recordTableName,
effectiveColumnName,
isDesignMode,
]);
// 파일 동기화 // 파일 동기화
useEffect(() => { useEffect(() => {
@ -344,7 +370,8 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
} }
let targetObjid; let targetObjid;
const effectiveIsRecordMode = isRecordMode || (effectiveRecordId && !String(effectiveRecordId).startsWith('temp_')); const effectiveIsRecordMode =
isRecordMode || (effectiveRecordId && !String(effectiveRecordId).startsWith("temp_"));
if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) { if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) {
targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`; targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`;
@ -358,7 +385,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
const finalLinkedTable = effectiveIsRecordMode const finalLinkedTable = effectiveIsRecordMode
? effectiveTableName ? effectiveTableName
: (formData?.linkedTable || effectiveTableName); : formData?.linkedTable || effectiveTableName;
const uploadData = { const uploadData = {
autoLink: formData?.autoLink || true, autoLink: formData?.autoLink || true,
@ -474,9 +501,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
if (onFormDataChange && targetColumn) { if (onFormDataChange && targetColumn) {
// 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값) // 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값)
// 복수 파일: 콤마 구분 문자열로 전달 // 복수 파일: 콤마 구분 문자열로 전달
const formValue = config.multiple const formValue = config.multiple ? fileIds.join(",") : fileIds[0] || "";
? fileIds.join(',')
: (fileIds[0] || '');
console.log("📝 [V2Media] formData 업데이트:", { console.log("📝 [V2Media] formData 업데이트:", {
columnName: targetColumn, columnName: targetColumn,
@ -515,7 +540,22 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
toast.error(`업로드 오류: ${error instanceof Error ? error.message : "알 수 없는 오류"}`); toast.error(`업로드 오류: ${error instanceof Error ? error.message : "알 수 없는 오류"}`);
} }
}, },
[config, uploadedFiles, onChange, id, getUniqueKey, recordId, isRecordMode, recordTableName, effectiveColumnName, tableName, onUpdate, onFormDataChange, user, columnName], [
config,
uploadedFiles,
onChange,
id,
getUniqueKey,
recordId,
isRecordMode,
recordTableName,
effectiveColumnName,
tableName,
onUpdate,
onFormDataChange,
user,
columnName,
],
); );
// 파일 뷰어 열기/닫기 // 파일 뷰어 열기/닫기
@ -612,9 +652,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
if (onFormDataChange && targetColumn) { if (onFormDataChange && targetColumn) {
// 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값) // 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값)
// 복수 파일: 콤마 구분 문자열로 전달 // 복수 파일: 콤마 구분 문자열로 전달
const formValue = config.multiple const formValue = config.multiple ? fileIds.join(",") : fileIds[0] || "";
? fileIds.join(',')
: (fileIds[0] || '');
console.log("🗑️ [V2Media] 삭제 후 formData 업데이트:", { console.log("🗑️ [V2Media] 삭제 후 formData 업데이트:", {
columnName: targetColumn, columnName: targetColumn,
@ -631,7 +669,20 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
toast.error("파일 삭제 실패"); toast.error("파일 삭제 실패");
} }
}, },
[uploadedFiles, onUpdate, id, isRecordMode, onFormDataChange, recordTableName, recordId, effectiveColumnName, getUniqueKey, onChange, config.multiple, columnName], [
uploadedFiles,
onUpdate,
id,
isRecordMode,
onFormDataChange,
recordTableName,
recordId,
effectiveColumnName,
getUniqueKey,
onChange,
config.multiple,
columnName,
],
); );
// 대표 이미지 로드 // 대표 이미지 로드
@ -639,7 +690,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
async (file: FileInfo) => { async (file: FileInfo) => {
try { try {
const isImage = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes( const isImage = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(
file.fileExt.toLowerCase().replace(".", "") file.fileExt.toLowerCase().replace(".", ""),
); );
if (!isImage) { if (!isImage) {
@ -691,12 +742,12 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
console.error("대표 파일 설정 실패:", e); console.error("대표 파일 설정 실패:", e);
} }
}, },
[uploadedFiles, loadRepresentativeImage] [uploadedFiles, loadRepresentativeImage],
); );
// uploadedFiles 변경 시 대표 이미지 로드 // uploadedFiles 변경 시 대표 이미지 로드
useEffect(() => { useEffect(() => {
const representativeFile = uploadedFiles.find(f => f.isRepresentative) || uploadedFiles[0]; const representativeFile = uploadedFiles.find((f) => f.isRepresentative) || uploadedFiles[0];
if (representativeFile) { if (representativeFile) {
loadRepresentativeImage(representativeFile); loadRepresentativeImage(representativeFile);
} else { } else {
@ -711,13 +762,16 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
}, [uploadedFiles]); }, [uploadedFiles]);
// 드래그 앤 드롭 핸들러 // 드래그 앤 드롭 핸들러
const handleDragOver = useCallback((e: React.DragEvent) => { const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (!readonly && !disabled) { if (!readonly && !disabled) {
setDragOver(true); setDragOver(true);
} }
}, [readonly, disabled]); },
[readonly, disabled],
);
const handleDragLeave = useCallback((e: React.DragEvent) => { const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@ -725,7 +779,8 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
setDragOver(false); setDragOver(false);
}, []); }, []);
const handleDrop = useCallback((e: React.DragEvent) => { const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setDragOver(false); setDragOver(false);
@ -736,7 +791,9 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
handleFileUpload(files); handleFileUpload(files);
} }
} }
}, [readonly, disabled, handleFileUpload]); },
[readonly, disabled, handleFileUpload],
);
// 파일 선택 // 파일 선택
const handleFileSelect = useCallback(() => { const handleFileSelect = useCallback(() => {
@ -745,13 +802,16 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
} }
}, []); }, []);
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
if (files.length > 0) { if (files.length > 0) {
handleFileUpload(files); handleFileUpload(files);
} }
e.target.value = ''; e.target.value = "";
}, [handleFileUpload]); },
[handleFileUpload],
);
// 파일 설정 // 파일 설정
const fileConfig: FileUploadConfig = { const fileConfig: FileUploadConfig = {
@ -767,12 +827,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
const componentHeight = size?.height || style?.height; const componentHeight = size?.height || style?.height;
return ( return (
<div <div ref={ref} id={id} className="flex w-full flex-col" style={{ width: componentWidth }}>
ref={ref}
id={id}
className="flex w-full flex-col"
style={{ width: componentWidth }}
>
{/* 라벨 */} {/* 라벨 */}
{showLabel && ( {showLabel && (
<Label <Label
@ -783,20 +838,17 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
fontWeight: style?.labelFontWeight, fontWeight: style?.labelFontWeight,
marginBottom: style?.labelMarginBottom, marginBottom: style?.labelMarginBottom,
}} }}
className="text-sm font-medium shrink-0" className="shrink-0 text-sm font-medium"
> >
{label} {label}
{required && <span className="text-orange-500 ml-0.5">*</span>} {required && <span className="ml-0.5 text-orange-500">*</span>}
</Label> </Label>
)} )}
{/* 메인 컨테이너 */} {/* 메인 컨테이너 */}
<div className="min-h-0" style={{ height: componentHeight }}>
<div <div
className="min-h-0" className="border-border bg-card relative flex h-full w-full flex-col overflow-hidden rounded-lg border"
style={{ height: componentHeight }}
>
<div
className="border-border bg-card relative flex h-full w-full flex-col rounded-lg border overflow-hidden"
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
@ -813,16 +865,19 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
/> />
{/* 파일이 있는 경우: 대표 이미지/파일 표시 */} {/* 파일이 있는 경우: 대표 이미지/파일 표시 */}
{uploadedFiles.length > 0 ? (() => { {uploadedFiles.length > 0 ? (
const representativeFile = uploadedFiles.find(f => f.isRepresentative) || uploadedFiles[0]; (() => {
const isImage = representativeFile && ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes( const representativeFile = uploadedFiles.find((f) => f.isRepresentative) || uploadedFiles[0];
representativeFile.fileExt.toLowerCase().replace(".", "") const isImage =
representativeFile &&
["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(
representativeFile.fileExt.toLowerCase().replace(".", ""),
); );
return ( return (
<> <>
{isImage && representativeImageUrl ? ( {isImage && representativeImageUrl ? (
<div className="relative h-full w-full flex items-center justify-center bg-muted/10"> <div className="bg-muted/10 relative flex h-full w-full items-center justify-center">
<img <img
src={representativeImageUrl} src={representativeImageUrl}
alt={representativeFile.realFileName} alt={representativeFile.realFileName}
@ -831,15 +886,13 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
</div> </div>
) : isImage && !representativeImageUrl ? ( ) : isImage && !representativeImageUrl ? (
<div className="flex h-full w-full flex-col items-center justify-center"> <div className="flex h-full w-full flex-col items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div> <div className="border-primary mb-2 h-8 w-8 animate-spin rounded-full border-b-2"></div>
<p className="text-sm text-muted-foreground"> ...</p> <p className="text-muted-foreground text-sm"> ...</p>
</div> </div>
) : ( ) : (
<div className="flex h-full w-full flex-col items-center justify-center"> <div className="flex h-full w-full flex-col items-center justify-center">
{getFileIcon(representativeFile.fileExt)} {getFileIcon(representativeFile.fileExt)}
<p className="mt-3 text-sm font-medium text-center px-4"> <p className="mt-3 px-4 text-center text-sm font-medium">{representativeFile.realFileName}</p>
{representativeFile.realFileName}
</p>
<Badge variant="secondary" className="mt-2"> <Badge variant="secondary" className="mt-2">
</Badge> </Badge>
@ -847,7 +900,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
)} )}
{/* 우측 하단 자세히보기 버튼 */} {/* 우측 하단 자세히보기 버튼 */}
<div className="absolute bottom-3 right-3"> <div className="absolute right-3 bottom-3">
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
@ -859,19 +912,20 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
</div> </div>
</> </>
); );
})() : ( })()
) : (
// 파일이 없는 경우: 업로드 안내 // 파일이 없는 경우: 업로드 안내
<div <div
className={cn( className={cn(
"flex h-full w-full flex-col items-center justify-center text-muted-foreground cursor-pointer", "text-muted-foreground flex h-full w-full cursor-pointer flex-col items-center justify-center",
dragOver && "border-primary bg-primary/5", dragOver && "border-primary bg-primary/5",
(disabled || readonly) && "opacity-50 cursor-not-allowed" (disabled || readonly) && "cursor-not-allowed opacity-50",
)} )}
onClick={() => !disabled && !readonly && handleFileSelect()} onClick={() => !disabled && !readonly && handleFileSelect()}
> >
<Upload className="mb-3 h-12 w-12" /> <Upload className="mb-3 h-12 w-12" />
<p className="text-sm font-medium"> </p> <p className="text-sm font-medium"> </p>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1 text-xs">
{formatFileSize(config.maxSize || 10 * 1024 * 1024)} {formatFileSize(config.maxSize || 10 * 1024 * 1024)}
{config.accept && config.accept !== "*/*" && ` (${config.accept})`} {config.accept && config.accept !== "*/*" && ` (${config.accept})`}
</p> </p>
@ -916,8 +970,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
/> />
</div> </div>
); );
} });
);
V2Media.displayName = "V2Media"; V2Media.displayName = "V2Media";

View File

@ -58,16 +58,24 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
}, ref) => { }, ref) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// 현재 선택된 값 존재 여부
const hasValue = useMemo(() => {
if (!value) return false;
if (Array.isArray(value)) return value.length > 0;
return value !== "";
}, [value]);
// 단일 선택 + 검색 불가능 → 기본 Select 사용 // 단일 선택 + 검색 불가능 → 기본 Select 사용
if (!searchable && !multiple) { if (!searchable && !multiple) {
return ( return (
<div className="relative w-full group">
<Select <Select
value={typeof value === "string" ? value : value?.[0] ?? ""} value={typeof value === "string" ? value : value?.[0] ?? ""}
onValueChange={(v) => onChange?.(v)} onValueChange={(v) => onChange?.(v)}
disabled={disabled} disabled={disabled}
> >
{/* SelectTrigger에 style로 직접 height 전달 (Radix Select.Root는 DOM 없어서 h-full 체인 끊김) */} {/* SelectTrigger에 style로 직접 height 전달 (Radix Select.Root는 DOM 없어서 h-full 체인 끊김) */}
<SelectTrigger ref={ref} className={cn("w-full", className)} style={style}> <SelectTrigger ref={ref} className={cn("w-full", allowClear && hasValue ? "pr-8" : "", className)} style={style}>
<SelectValue placeholder={placeholder} /> <SelectValue placeholder={placeholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -80,6 +88,26 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{/* 초기화 버튼 (값이 있을 때만 표시) */}
{allowClear && hasValue && !disabled && (
<span
role="button"
tabIndex={-1}
className="absolute right-7 top-1/2 -translate-y-1/2 z-10 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onChange?.("");
}}
onPointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<X className="h-3.5 w-3.5 opacity-40 hover:opacity-100 transition-opacity" />
</span>
)}
</div>
); );
} }
@ -142,10 +170,18 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
</span> </span>
<div className="flex items-center gap-1 ml-2"> <div className="flex items-center gap-1 ml-2">
{allowClear && selectedValues.length > 0 && ( {allowClear && selectedValues.length > 0 && (
<X <span
className="h-4 w-4 opacity-50 hover:opacity-100" role="button"
tabIndex={-1}
onClick={handleClear} onClick={handleClear}
/> onPointerDown={(e) => {
// Radix Popover가 onPointerDown으로 팝오버를 여는 것을 방지
e.stopPropagation();
e.preventDefault();
}}
>
<X className="h-4 w-4 opacity-50 hover:opacity-100" />
</span>
)} )}
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</div> </div>

View File

@ -21,7 +21,7 @@ import {
MODAL_SIZE_OPTIONS, MODAL_SIZE_OPTIONS,
SECTION_TYPE_OPTIONS, SECTION_TYPE_OPTIONS,
} from "./types"; } from "./types";
import { defaultSectionConfig, defaultTableSectionConfig, generateSectionId } from "./config"; import { defaultConfig, defaultSectionConfig, defaultTableSectionConfig, generateSectionId } from "./config";
// 모달 import // 모달 import
import { FieldDetailSettingsModal } from "./modals/FieldDetailSettingsModal"; import { FieldDetailSettingsModal } from "./modals/FieldDetailSettingsModal";
@ -43,10 +43,20 @@ interface AvailableParentField {
} }
export function UniversalFormModalConfigPanel({ export function UniversalFormModalConfigPanel({
config, config: rawConfig,
onChange, onChange,
allComponents = [], allComponents = [],
}: UniversalFormModalConfigPanelProps) { }: UniversalFormModalConfigPanelProps) {
// config가 불완전할 수 있으므로 defaultConfig와 병합하여 안전하게 사용
const config: UniversalFormModalConfig = {
...defaultConfig,
...rawConfig,
modal: { ...defaultConfig.modal, ...rawConfig?.modal },
sections: rawConfig?.sections ?? defaultConfig.sections,
saveConfig: { ...defaultConfig.saveConfig, ...rawConfig?.saveConfig },
editMode: { ...defaultConfig.editMode, ...rawConfig?.editMode },
};
// 테이블 목록 // 테이블 목록
const [tables, setTables] = useState<{ name: string; label: string }[]>([]); const [tables, setTables] = useState<{ name: string; label: string }[]>([]);
const [tableColumns, setTableColumns] = useState<{ const [tableColumns, setTableColumns] = useState<{
@ -255,10 +265,10 @@ export function UniversalFormModalConfigPanel({
// 저장 테이블 변경 시 컬럼 로드 // 저장 테이블 변경 시 컬럼 로드
useEffect(() => { useEffect(() => {
if (config.saveConfig.tableName) { if (config.saveConfig?.tableName) {
loadTableColumns(config.saveConfig.tableName); loadTableColumns(config.saveConfig.tableName);
} }
}, [config.saveConfig.tableName]); }, [config.saveConfig?.tableName]);
const loadTables = async () => { const loadTables = async () => {
try { try {
@ -564,9 +574,9 @@ export function UniversalFormModalConfigPanel({
<div className="w-full min-w-0 space-y-3"> <div className="w-full min-w-0 space-y-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<Label className="mb-1.5 block text-xs font-medium"> </Label> <Label className="mb-1.5 block text-xs font-medium"> </Label>
<p className="text-muted-foreground text-sm">{config.saveConfig.tableName || "(미설정)"}</p> <p className="text-muted-foreground text-sm">{config.saveConfig?.tableName || "(미설정)"}</p>
{config.saveConfig.customApiSave?.enabled && {config.saveConfig?.customApiSave?.enabled &&
config.saveConfig.customApiSave?.multiTable?.enabled && ( config.saveConfig?.customApiSave?.multiTable?.enabled && (
<Badge variant="secondary" className="mt-2 px-2 py-0.5 text-xs"> <Badge variant="secondary" className="mt-2 px-2 py-0.5 text-xs">
</Badge> </Badge>
@ -816,9 +826,9 @@ export function UniversalFormModalConfigPanel({
setSelectedField(field); setSelectedField(field);
setFieldDetailModalOpen(true); setFieldDetailModalOpen(true);
}} }}
tableName={config.saveConfig.tableName} tableName={config.saveConfig?.tableName}
tableColumns={ tableColumns={
tableColumns[config.saveConfig.tableName || ""]?.map((col) => ({ tableColumns[config.saveConfig?.tableName || ""]?.map((col) => ({
name: col.name, name: col.name,
type: col.type, type: col.type,
label: col.label || col.name, label: col.label || col.name,

View File

@ -68,22 +68,22 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
const getFileIcon = (fileExt: string) => { const getFileIcon = (fileExt: string) => {
const ext = fileExt.toLowerCase(); const ext = fileExt.toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) { if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) {
return <ImageIcon className="w-5 h-5 text-blue-500" />; return <ImageIcon className="h-5 w-5 text-blue-500" />;
} else if (['pdf', 'doc', 'docx', 'txt', 'rtf'].includes(ext)) { } else if (["pdf", "doc", "docx", "txt", "rtf"].includes(ext)) {
return <FileText className="w-5 h-5 text-red-500" />; return <FileText className="h-5 w-5 text-red-500" />;
} else if (['xls', 'xlsx', 'csv'].includes(ext)) { } else if (["xls", "xlsx", "csv"].includes(ext)) {
return <FileText className="w-5 h-5 text-green-500" />; return <FileText className="h-5 w-5 text-green-500" />;
} else if (['ppt', 'pptx'].includes(ext)) { } else if (["ppt", "pptx"].includes(ext)) {
return <Presentation className="w-5 h-5 text-orange-500" />; return <Presentation className="h-5 w-5 text-orange-500" />;
} else if (['mp4', 'avi', 'mov', 'webm'].includes(ext)) { } else if (["mp4", "avi", "mov", "webm"].includes(ext)) {
return <Video className="w-5 h-5 text-purple-500" />; return <Video className="h-5 w-5 text-purple-500" />;
} else if (['mp3', 'wav', 'ogg'].includes(ext)) { } else if (["mp3", "wav", "ogg"].includes(ext)) {
return <Music className="w-5 h-5 text-pink-500" />; return <Music className="h-5 w-5 text-pink-500" />;
} else if (['zip', 'rar', '7z'].includes(ext)) { } else if (["zip", "rar", "7z"].includes(ext)) {
return <Archive className="w-5 h-5 text-yellow-500" />; return <Archive className="h-5 w-5 text-yellow-500" />;
} else { } else {
return <File className="w-5 h-5 text-gray-500" />; return <File className="h-5 w-5 text-gray-500" />;
} }
}; };
@ -95,12 +95,12 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
try { try {
const fileArray = Array.from(files); const fileArray = Array.from(files);
await onFileUpload(fileArray); await onFileUpload(fileArray);
console.log('✅ FileManagerModal: 파일 업로드 완료'); console.log("✅ FileManagerModal: 파일 업로드 완료");
} catch (error) { } catch (error) {
console.error('❌ FileManagerModal: 파일 업로드 오류:', error); console.error("❌ FileManagerModal: 파일 업로드 오류:", error);
} finally { } finally {
setUploading(false); setUploading(false);
console.log('🔄 FileManagerModal: 업로드 상태 초기화'); console.log("🔄 FileManagerModal: 업로드 상태 초기화");
} }
}; };
@ -137,7 +137,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
handleFileUpload(files); handleFileUpload(files);
} }
// 입력값 초기화 // 입력값 초기화
e.target.value = ''; e.target.value = "";
}; };
// 파일 뷰어 핸들러 // 파일 뷰어 핸들러
@ -159,8 +159,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
// 이미지 파일인 경우 미리보기 로드 // 이미지 파일인 경우 미리보기 로드
// 🔑 점(.)을 제거하고 확장자만 비교 // 🔑 점(.)을 제거하고 확장자만 비교
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']; const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg"];
const ext = file.fileExt.toLowerCase().replace('.', ''); const ext = file.fileExt.toLowerCase().replace(".", "");
if (imageExtensions.includes(ext) || file.isImage) { if (imageExtensions.includes(ext) || file.isImage) {
try { try {
// 이전 Blob URL 해제 // 이전 Blob URL 해제
@ -171,7 +171,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
// 🔑 항상 apiClient를 통해 Blob 다운로드 (Docker 환경에서 상대 경로 문제 방지) // 🔑 항상 apiClient를 통해 Blob 다운로드 (Docker 환경에서 상대 경로 문제 방지)
const { apiClient } = await import("@/lib/api/client"); const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/files/preview/${file.objid}`, { const response = await apiClient.get(`/files/preview/${file.objid}`, {
responseType: 'blob' responseType: "blob",
}); });
const blob = new Blob([response.data]); const blob = new Blob([response.data]);
@ -238,32 +238,19 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
return ( return (
<> <>
<Dialog open={isOpen} onOpenChange={() => {}}> <Dialog open={isOpen} onOpenChange={() => {}}>
<DialogContent className="max-w-[95vw] w-[1400px] max-h-[90vh] overflow-hidden [&>button]:hidden"> <DialogContent className="max-h-[90vh] w-[1400px] max-w-[95vw] overflow-hidden [&>button]:hidden">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<DialogTitle className="text-lg font-semibold"> <DialogTitle className="text-lg font-semibold"> ({uploadedFiles.length})</DialogTitle>
({uploadedFiles.length}) <Button variant="ghost" size="sm" className="h-8 w-8 p-0 hover:bg-gray-100" onClick={onClose} title="닫기">
</DialogTitle> <X className="h-4 w-4" />
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:bg-gray-100"
onClick={onClose}
title="닫기"
>
<X className="w-4 h-4" />
</Button> </Button>
</DialogHeader> </DialogHeader>
<div className="flex flex-col space-y-3 h-[75vh]"> <div className="flex h-[75vh] flex-col space-y-3">
{/* 파일 업로드 영역 - 높이 축소 */} {/* 파일 업로드 영역 - 높이 축소 */}
{!isDesignMode && ( {!isDesignMode && (
<div <div
className={` 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" : ""} `}
border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
${config.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
${uploading ? 'opacity-75' : ''}
`}
onClick={() => { onClick={() => {
if (!config.disabled && !isDesignMode) { if (!config.disabled && !isDesignMode) {
fileInputRef.current?.click(); fileInputRef.current?.click();
@ -285,44 +272,40 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
{uploading ? ( {uploading ? (
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div> <div className="h-5 w-5 animate-spin rounded-full border-b-2 border-blue-600"></div>
<span className="text-sm text-blue-600 font-medium"> ...</span> <span className="text-sm font-medium text-blue-600"> ...</span>
</div> </div>
) : ( ) : (
<div className="flex items-center justify-center gap-3"> <div className="flex items-center justify-center gap-3">
<Upload className="h-6 w-6 text-gray-400" /> <Upload className="h-6 w-6 text-gray-400" />
<p className="text-sm font-medium text-gray-700"> <p className="text-sm font-medium text-gray-700"> </p>
</p>
</div> </div>
)} )}
</div> </div>
)} )}
{/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */} {/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
<div className="flex-1 flex gap-4 min-h-0"> <div className="flex min-h-0 flex-1 gap-4">
{/* 좌측: 이미지 미리보기 (확대/축소 가능) */} {/* 좌측: 이미지 미리보기 (확대/축소 가능) */}
<div className="flex-1 border border-gray-200 rounded-lg bg-gray-900 flex flex-col overflow-hidden relative"> <div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border border-gray-200 bg-gray-900">
{/* 확대/축소 컨트롤 */} {/* 확대/축소 컨트롤 */}
{selectedFile && previewImageUrl && ( {selectedFile && previewImageUrl && (
<div className="absolute top-3 left-3 z-10 flex items-center gap-1 bg-black/60 rounded-lg p-1"> <div className="absolute top-3 left-3 z-10 flex items-center gap-1 rounded-lg bg-black/60 p-1">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-white hover:bg-white/20" className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setZoomLevel(prev => Math.max(0.25, prev - 0.25))} onClick={() => setZoomLevel((prev) => Math.max(0.25, prev - 0.25))}
disabled={zoomLevel <= 0.25} disabled={zoomLevel <= 0.25}
> >
<ZoomOut className="h-4 w-4" /> <ZoomOut className="h-4 w-4" />
</Button> </Button>
<span className="text-white text-xs min-w-[50px] text-center"> <span className="min-w-[50px] text-center text-xs text-white">{Math.round(zoomLevel * 100)}%</span>
{Math.round(zoomLevel * 100)}%
</span>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-white hover:bg-white/20" className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setZoomLevel(prev => Math.min(4, prev + 0.25))} onClick={() => setZoomLevel((prev) => Math.min(4, prev + 0.25))}
disabled={zoomLevel >= 4} disabled={zoomLevel >= 4}
> >
<ZoomIn className="h-4 w-4" /> <ZoomIn className="h-4 w-4" />
@ -341,14 +324,14 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
{/* 이미지 미리보기 영역 - 마우스 휠로 확대/축소, 드래그로 이동 */} {/* 이미지 미리보기 영역 - 마우스 휠로 확대/축소, 드래그로 이동 */}
<div <div
ref={imageContainerRef} ref={imageContainerRef}
className={`flex-1 flex items-center justify-center overflow-hidden p-4 ${ className={`flex flex-1 items-center justify-center overflow-hidden p-4 ${
zoomLevel > 1 ? (isDragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in' zoomLevel > 1 ? (isDragging ? "cursor-grabbing" : "cursor-grab") : "cursor-zoom-in"
}`} }`}
onWheel={(e) => { onWheel={(e) => {
if (selectedFile && previewImageUrl) { if (selectedFile && previewImageUrl) {
e.preventDefault(); e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1; const delta = e.deltaY > 0 ? -0.1 : 0.1;
setZoomLevel(prev => Math.min(4, Math.max(0.25, prev + delta))); setZoomLevel((prev) => Math.min(4, Math.max(0.25, prev + delta)));
} }
}} }}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
@ -363,7 +346,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
className="transition-transform duration-100 select-none" className="transition-transform duration-100 select-none"
style={{ style={{
transform: `translate(${imagePosition.x}px, ${imagePosition.y}px) scale(${zoomLevel})`, transform: `translate(${imagePosition.x}px, ${imagePosition.y}px) scale(${zoomLevel})`,
transformOrigin: 'center center', transformOrigin: "center center",
}} }}
draggable={false} draggable={false}
/> />
@ -374,7 +357,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center text-gray-400"> <div className="flex flex-col items-center text-gray-400">
<ImageIcon className="w-16 h-16 mb-2" /> <ImageIcon className="mb-2 h-16 w-16" />
<p className="text-sm"> </p> <p className="text-sm"> </p>
</div> </div>
)} )}
@ -382,19 +365,17 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
{/* 파일 정보 바 */} {/* 파일 정보 바 */}
{selectedFile && ( {selectedFile && (
<div className="bg-black/60 text-white text-xs px-3 py-2 text-center truncate"> <div className="truncate bg-black/60 px-3 py-2 text-center text-xs text-white">
{selectedFile.realFileName} {selectedFile.realFileName}
</div> </div>
)} )}
</div> </div>
{/* 우측: 파일 목록 (고정 너비) */} {/* 우측: 파일 목록 (고정 너비) */}
<div className="w-[400px] shrink-0 border border-gray-200 rounded-lg overflow-hidden flex flex-col"> <div className="flex w-[400px] shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200">
<div className="p-3 border-b border-gray-200 bg-gray-50"> <div className="border-b border-gray-200 bg-gray-50 p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700"> <h3 className="text-sm font-medium text-gray-700"> </h3>
</h3>
{uploadedFiles.length > 0 && ( {uploadedFiles.length > 0 && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))} {formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
@ -409,20 +390,13 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
{uploadedFiles.map((file) => ( {uploadedFiles.map((file) => (
<div <div
key={file.objid} key={file.objid}
className={` className={`flex cursor-pointer items-center space-x-3 rounded-lg p-2 transition-colors ${selectedFile?.objid === file.objid ? "border border-blue-200 bg-blue-50" : "bg-gray-50 hover:bg-gray-100"} `}
flex items-center space-x-3 p-2 rounded-lg transition-colors cursor-pointer
${selectedFile?.objid === file.objid ? 'bg-blue-50 border border-blue-200' : 'bg-gray-50 hover:bg-gray-100'}
`}
onClick={() => handleFileClick(file)} onClick={() => handleFileClick(file)}
> >
<div className="flex-shrink-0"> <div className="flex-shrink-0">{getFileIcon(file.fileExt)}</div>
{getFileIcon(file.fileExt)} <div className="min-w-0 flex-1">
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 truncate"> <span className="truncate text-sm font-medium text-gray-900">{file.realFileName}</span>
{file.realFileName}
</span>
{file.isRepresentative && ( {file.isRepresentative && (
<Badge variant="default" className="h-5 px-1.5 text-xs"> <Badge variant="default" className="h-5 px-1.5 text-xs">
@ -445,7 +419,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
}} }}
title={file.isRepresentative ? "현재 대표 파일" : "대표 파일로 설정"} title={file.isRepresentative ? "현재 대표 파일" : "대표 파일로 설정"}
> >
<Star className={`w-3 h-3 ${file.isRepresentative ? "fill-white" : ""}`} /> <Star className={`h-3 w-3 ${file.isRepresentative ? "fill-white" : ""}`} />
</Button> </Button>
)} )}
<Button <Button
@ -458,7 +432,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
}} }}
title="미리보기" title="미리보기"
> >
<Eye className="w-3 h-3" /> <Eye className="h-3 w-3" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@ -470,7 +444,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
}} }}
title="다운로드" title="다운로드"
> >
<Download className="w-3 h-3" /> <Download className="h-3 w-3" />
</Button> </Button>
{!isDesignMode && ( {!isDesignMode && (
<Button <Button
@ -483,7 +457,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
}} }}
title="삭제" title="삭제"
> >
<Trash2 className="w-3 h-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
)} )}
</div> </div>
@ -492,10 +466,12 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center py-8 text-gray-500"> <div className="flex flex-col items-center justify-center py-8 text-gray-500">
<File className="w-12 h-12 mb-3 text-gray-300" /> <File className="mb-3 h-12 w-12 text-gray-300" />
<p className="text-sm font-medium text-gray-600"> </p> <p className="text-sm font-medium text-gray-600"> </p>
<p className="text-xs text-gray-500 mt-1"> <p className="mt-1 text-xs text-gray-500">
{isDesignMode ? '디자인 모드에서는 파일을 업로드할 수 없습니다' : '위의 영역에 파일을 업로드하세요'} {isDesignMode
? "디자인 모드에서는 파일을 업로드할 수 없습니다"
: "위의 영역에 파일을 업로드하세요"}
</p> </p>
</div> </div>
)} )}

View File

@ -70,18 +70,18 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
} }
// 🆕 formData에 값이 없고 기본값이 설정된 경우, 기본값 적용 // 🆕 formData에 값이 없고 기본값이 설정된 경우, 기본값 적용
// 단, formData에 해당 키가 이미 존재하면(사용자가 명시적으로 초기화한 경우) 기본값을 재적용하지 않음
const hasKeyInFormData = formData !== undefined && formData !== null && columnName in (formData || {});
if ( if (
(currentValue === "" || currentValue === undefined || currentValue === null) && (currentValue === "" || currentValue === undefined || currentValue === null) &&
defaultValue && defaultValue &&
isInteractive && isInteractive &&
onFormDataChange && onFormDataChange &&
columnName columnName &&
!hasKeyInFormData // formData에 키 자체가 없을 때만 기본값 적용 (초기 렌더링)
) { ) {
// 초기 렌더링 시 기본값을 formData에 설정
setTimeout(() => { setTimeout(() => {
if (!formData?.[columnName]) {
onFormDataChange(columnName, defaultValue); onFormDataChange(columnName, defaultValue);
}
}, 0); }, 0);
currentValue = defaultValue; currentValue = defaultValue;
} }