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("이 화면의 레이아웃을 저장할 권한이 없습니다.");
}
// 화면의 기본 테이블 업데이트 (테이블이 선택된 경우)
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에서 레이어 메타 정보 제거 (순수 레이아웃만 저장)
const { layerId: _lid, layerName: _ln, conditionConfig: _cc, ...pureLayoutData } = layoutData;
const { layerId: _lid, layerName: _ln, conditionConfig: _cc, mainTableName: _mtn, ...pureLayoutData } = layoutData;
const dataToSave = {
version: "2.0",
...pureLayoutData,

View File

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

View File

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

View File

@ -44,6 +44,11 @@ interface EntityJoinTable {
tableName: string;
currentDisplayColumn: string;
availableColumns: EntityJoinColumn[];
// 같은 테이블이 여러 FK로 조인될 수 있으므로 소스 컬럼으로 구분
joinConfig?: {
sourceColumn: string;
[key: string]: unknown;
};
}
interface TablesPanelProps {
@ -414,7 +419,11 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
</Badge>
</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 filteredColumns = searchTerm
@ -431,8 +440,7 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
}
return (
// 엔티티 조인 테이블에 고유 접두사 추가 (메인 테이블과 키 중복 방지)
<div key={`entity-join-${joinTable.tableName}`} className="space-y-1">
<div key={uniqueKey} className="space-y-1">
{/* 조인 테이블 헤더 */}
<div
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 [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({});
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
// 탭별 화면 정보 (screenId, tableName) 저장
const [screenInfoMap, setScreenInfoMap] = useState<Record<string, { id: number; tableName?: string }>>({});
// 탭별 화면 정보 (screenId, tableName) - 인라인 컴포넌트의 테이블 설정에서 추출
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(() => {
@ -157,21 +176,10 @@ export function TabsWidget({
) {
setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: true }));
try {
// 레이아웃과 화면 정보를 병렬로 로드
const [layoutData, screenDef] = await Promise.all([
screenApi.getLayout(extTab.screenId),
screenApi.getScreen(extTab.screenId),
]);
const layoutData = await screenApi.getLayout(extTab.screenId);
if (layoutData && 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) {
console.error(`탭 "${tab.label}" 화면 로드 실패:`, error);
setScreenErrors((prev) => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." }));
@ -185,31 +193,6 @@ export function TabsWidget({
loadScreenLayouts();
}, [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 업데이트
useEffect(() => {
if (persistSelection && typeof window !== "undefined") {

View File

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

View File

@ -58,16 +58,24 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
}, ref) => {
const [open, setOpen] = useState(false);
// 현재 선택된 값 존재 여부
const hasValue = useMemo(() => {
if (!value) return false;
if (Array.isArray(value)) return value.length > 0;
return value !== "";
}, [value]);
// 단일 선택 + 검색 불가능 → 기본 Select 사용
if (!searchable && !multiple) {
return (
<div className="relative w-full group">
<Select
value={typeof value === "string" ? value : value?.[0] ?? ""}
onValueChange={(v) => onChange?.(v)}
disabled={disabled}
>
{/* 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} />
</SelectTrigger>
<SelectContent>
@ -80,6 +88,26 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
))}
</SelectContent>
</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>
<div className="flex items-center gap-1 ml-2">
{allowClear && selectedValues.length > 0 && (
<X
className="h-4 w-4 opacity-50 hover:opacity-100"
<span
role="button"
tabIndex={-1}
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" />
</div>

View File

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

View File

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

View File

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