Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management

This commit is contained in:
kjs 2025-11-06 11:58:09 +09:00
commit e25f8893b0
16 changed files with 749 additions and 465 deletions

View File

@ -232,7 +232,11 @@ export const uploadFiles = async (
// 자동 연결 로직 - target_objid 자동 생성
let finalTargetObjid = targetObjid;
if (autoLink === "true" && linkedTable && recordId) {
// 🔑 템플릿 파일(screen_files:)이나 temp_ 파일은 autoLink 무시
const isTemplateFile = targetObjid && (targetObjid.startsWith('screen_files:') || targetObjid.startsWith('temp_'));
if (!isTemplateFile && autoLink === "true" && linkedTable && recordId) {
// 가상 파일 컬럼의 경우 컬럼명도 포함한 target_objid 생성
if (isVirtualFileColumn === "true" && columnName) {
finalTargetObjid = `${linkedTable}:${recordId}:${columnName}`;
@ -363,6 +367,38 @@ export const deleteFile = async (
const { objid } = req.params;
const { writer = "system" } = req.body;
// 🔒 멀티테넌시: 현재 사용자의 회사 코드
const companyCode = req.user?.companyCode;
// 파일 정보 조회
const fileRecord = await queryOne<any>(
`SELECT * FROM attach_file_info WHERE objid = $1`,
[parseInt(objid)]
);
if (!fileRecord) {
res.status(404).json({
success: false,
message: "파일을 찾을 수 없습니다.",
});
return;
}
// 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외)
if (companyCode !== "*" && fileRecord.company_code !== companyCode) {
console.warn("⚠️ 다른 회사 파일 삭제 시도:", {
userId: req.user?.userId,
userCompanyCode: companyCode,
fileCompanyCode: fileRecord.company_code,
objid,
});
res.status(403).json({
success: false,
message: "접근 권한이 없습니다.",
});
return;
}
// 파일 상태를 DELETED로 변경 (논리적 삭제)
await query<any>(
"UPDATE attach_file_info SET status = $1 WHERE objid = $2",
@ -510,6 +546,9 @@ export const getComponentFiles = async (
const { screenId, componentId, tableName, recordId, columnName } =
req.query;
// 🔒 멀티테넌시: 현재 사용자의 회사 코드 가져오기
const companyCode = req.user?.companyCode;
console.log("📂 [getComponentFiles] API 호출:", {
screenId,
componentId,
@ -517,6 +556,7 @@ export const getComponentFiles = async (
recordId,
columnName,
user: req.user?.userId,
companyCode, // 🔒 멀티테넌시 로그
});
if (!screenId || !componentId) {
@ -534,32 +574,16 @@ export const getComponentFiles = async (
templateTargetObjid,
});
// 모든 파일 조회해서 실제 저장된 target_objid 패턴 확인
const allFiles = await query<any>(
`SELECT target_objid, real_file_name, regdate
FROM attach_file_info
WHERE status = $1
ORDER BY regdate DESC
LIMIT 10`,
["ACTIVE"]
);
console.log(
"🗂️ [getComponentFiles] 최근 저장된 파일들의 target_objid:",
allFiles.map((f) => ({
target_objid: f.target_objid,
name: f.real_file_name,
}))
);
// 🔒 멀티테넌시: 회사별 필터링 추가
const templateFiles = await query<any>(
`SELECT * FROM attach_file_info
WHERE target_objid = $1 AND status = $2
WHERE target_objid = $1 AND status = $2 AND company_code = $3
ORDER BY regdate DESC`,
[templateTargetObjid, "ACTIVE"]
[templateTargetObjid, "ACTIVE", companyCode]
);
console.log(
"📁 [getComponentFiles] 템플릿 파일 결과:",
"📁 [getComponentFiles] 템플릿 파일 결과 (회사별 필터링):",
templateFiles.length
);
@ -567,11 +591,12 @@ export const getComponentFiles = async (
let dataFiles: any[] = [];
if (tableName && recordId && columnName) {
const dataTargetObjid = `${tableName}:${recordId}:${columnName}`;
// 🔒 멀티테넌시: 회사별 필터링 추가
dataFiles = await query<any>(
`SELECT * FROM attach_file_info
WHERE target_objid = $1 AND status = $2
WHERE target_objid = $1 AND status = $2 AND company_code = $3
ORDER BY regdate DESC`,
[dataTargetObjid, "ACTIVE"]
[dataTargetObjid, "ACTIVE", companyCode]
);
}
@ -591,6 +616,7 @@ export const getComponentFiles = async (
regdate: file.regdate?.toISOString(),
status: file.status,
isTemplate, // 템플릿 파일 여부 표시
isRepresentative: file.is_representative || false, // 대표 파일 여부
});
const formattedTemplateFiles = templateFiles.map((file) =>
@ -643,6 +669,9 @@ export const previewFile = async (
const { objid } = req.params;
const { serverFilename } = req.query;
// 🔒 멀티테넌시: 현재 사용자의 회사 코드
const companyCode = req.user?.companyCode;
const fileRecord = await queryOne<any>(
"SELECT * FROM attach_file_info WHERE objid = $1 LIMIT 1",
[parseInt(objid)]
@ -656,13 +685,28 @@ export const previewFile = async (
return;
}
// 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외)
if (companyCode !== "*" && fileRecord.company_code !== companyCode) {
console.warn("⚠️ 다른 회사 파일 접근 시도:", {
userId: req.user?.userId,
userCompanyCode: companyCode,
fileCompanyCode: fileRecord.company_code,
objid,
});
res.status(403).json({
success: false,
message: "접근 권한이 없습니다.",
});
return;
}
// 파일 경로에서 회사코드와 날짜 폴더 추출
const filePathParts = fileRecord.file_path!.split("/");
let companyCode = filePathParts[2] || "DEFAULT";
let fileCompanyCode = filePathParts[2] || "DEFAULT";
// company_* 처리 (실제 회사 코드로 변환)
if (companyCode === "company_*") {
companyCode = "company_*"; // 실제 디렉토리명 유지
if (fileCompanyCode === "company_*") {
fileCompanyCode = "company_*"; // 실제 디렉토리명 유지
}
const fileName = fileRecord.saved_file_name!;
@ -674,7 +718,7 @@ export const previewFile = async (
}
const companyUploadDir = getCompanyUploadDir(
companyCode,
fileCompanyCode,
dateFolder || undefined
);
const filePath = path.join(companyUploadDir, fileName);
@ -724,8 +768,9 @@ export const previewFile = async (
mimeType = "application/octet-stream";
}
// CORS 헤더 설정 (더 포괄적으로)
res.setHeader("Access-Control-Allow-Origin", "*");
// CORS 헤더 설정 (credentials 모드에서는 구체적인 origin 필요)
const origin = req.headers.origin || "http://localhost:9771";
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS"
@ -762,6 +807,9 @@ export const downloadFile = async (
try {
const { objid } = req.params;
// 🔒 멀티테넌시: 현재 사용자의 회사 코드
const companyCode = req.user?.companyCode;
const fileRecord = await queryOne<any>(
`SELECT * FROM attach_file_info WHERE objid = $1`,
[parseInt(objid)]
@ -775,13 +823,28 @@ export const downloadFile = async (
return;
}
// 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외)
if (companyCode !== "*" && fileRecord.company_code !== companyCode) {
console.warn("⚠️ 다른 회사 파일 다운로드 시도:", {
userId: req.user?.userId,
userCompanyCode: companyCode,
fileCompanyCode: fileRecord.company_code,
objid,
});
res.status(403).json({
success: false,
message: "접근 권한이 없습니다.",
});
return;
}
// 파일 경로에서 회사코드와 날짜 폴더 추출 (예: /uploads/company_*/2025/09/05/timestamp_filename.ext)
const filePathParts = fileRecord.file_path!.split("/");
let companyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출
let fileCompanyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출
// company_* 처리 (실제 회사 코드로 변환)
if (companyCode === "company_*") {
companyCode = "company_*"; // 실제 디렉토리명 유지
if (fileCompanyCode === "company_*") {
fileCompanyCode = "company_*"; // 실제 디렉토리명 유지
}
const fileName = fileRecord.saved_file_name!;
@ -794,7 +857,7 @@ export const downloadFile = async (
}
const companyUploadDir = getCompanyUploadDir(
companyCode,
fileCompanyCode,
dateFolder || undefined
);
const filePath = path.join(companyUploadDir, fileName);
@ -1026,5 +1089,68 @@ export const getFileByToken = async (req: Request, res: Response) => {
}
};
/**
*
*/
export const setRepresentativeFile = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { objid } = req.params;
const companyCode = req.user?.companyCode;
// 파일 존재 여부 및 권한 확인
const fileRecord = await queryOne<any>(
`SELECT * FROM attach_file_info WHERE objid = $1 AND status = $2`,
[parseInt(objid), "ACTIVE"]
);
if (!fileRecord) {
res.status(404).json({
success: false,
message: "파일을 찾을 수 없습니다.",
});
return;
}
// 멀티테넌시: 회사 코드 확인
if (companyCode !== "*" && fileRecord.company_code !== companyCode) {
res.status(403).json({
success: false,
message: "접근 권한이 없습니다.",
});
return;
}
// 같은 target_objid의 다른 파일들의 is_representative를 false로 설정
await query<any>(
`UPDATE attach_file_info
SET is_representative = false
WHERE target_objid = $1 AND objid != $2`,
[fileRecord.target_objid, parseInt(objid)]
);
// 선택한 파일을 대표 파일로 설정
await query<any>(
`UPDATE attach_file_info
SET is_representative = true
WHERE objid = $1`,
[parseInt(objid)]
);
res.json({
success: true,
message: "대표 파일이 설정되었습니다.",
});
} catch (error) {
console.error("대표 파일 설정 오류:", error);
res.status(500).json({
success: false,
message: "대표 파일 설정 중 오류가 발생했습니다.",
});
}
};
// Multer 미들웨어 export
export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일

View File

@ -10,6 +10,7 @@ import {
uploadMiddleware,
generateTempToken,
getFileByToken,
setRepresentativeFile,
} from "../controllers/fileController";
import { authenticateToken } from "../middleware/authMiddleware";
@ -84,4 +85,11 @@ router.get("/download/:objid", downloadFile);
*/
router.post("/temp-token/:objid", generateTempToken);
/**
* @route PUT /api/files/representative/:objid
* @desc
* @access Private
*/
router.put("/representative/:objid", setRepresentativeFile);
export default router;

View File

@ -15,7 +15,7 @@ export default function MainPage() {
<Card>
<CardContent className="pt-6">
<div className="space-y-6 text-center">
<h3 className="text-lg font-semibold">Vexolor에 !</h3>
<h3 className="text-lg font-semibold">Vexplor에 !</h3>
<p className="text-muted-foreground"> .</p>
<div className="flex justify-center space-x-2">
<Badge variant="secondary">Node.js</Badge>

View File

@ -26,80 +26,108 @@ import {
// 위젯 동적 임포트
const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const ExchangeWidget = dynamic(() => import("@/components/dashboard/widgets/ExchangeWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const CalculatorWidget = dynamic(() => import("@/components/dashboard/widgets/CalculatorWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const VehicleStatusWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleStatusWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const VehicleListWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleListWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const VehicleMapOnlyWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleMapOnlyWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 범용 지도 위젯 (차량, 창고, 고객 등 모든 위치 위젯 통합)
const MapSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/MapSummaryWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 🧪 테스트용 지도 위젯 (REST API 지원)
const MapTestWidget = dynamic(() => import("@/components/dashboard/widgets/MapTestWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 🧪 테스트용 지도 위젯 V2 (다중 데이터 소스)
const MapTestWidgetV2 = dynamic(() => import("@/components/dashboard/widgets/MapTestWidgetV2"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 🧪 테스트용 차트 위젯 (다중 데이터 소스)
const ChartTestWidget = dynamic(() => import("@/components/dashboard/widgets/ChartTestWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const ListTestWidget = dynamic(
() => import("@/components/dashboard/widgets/ListTestWidget").then((mod) => ({ default: mod.ListTestWidget })),
{
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
},
);
const CustomMetricTestWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricTestWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const RiskAlertTestWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertTestWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 범용 상태 요약 위젯 (차량, 배송 등 모든 상태 위젯 통합)
const StatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/StatusSummaryWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 범용 목록 위젯 (차량, 기사, 제품 등 모든 목록 위젯 통합) - 다른 분 작업 중, 임시 주석
@ -128,22 +156,30 @@ const StatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets
const RiskAlertWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const TaskWidget = dynamic(() => import("@/components/dashboard/widgets/TaskWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const BookingAlertWidget = dynamic(() => import("@/components/dashboard/widgets/BookingAlertWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const DocumentWidget = dynamic(() => import("@/components/dashboard/widgets/DocumentWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 시계 위젯 임포트
@ -160,25 +196,33 @@ import { Button } from "@/components/ui/button";
// 야드 관리 3D 위젯
const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 작업 이력 위젯
const WorkHistoryWidget = dynamic(() => import("@/components/dashboard/widgets/WorkHistoryWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 커스텀 통계 카드 위젯
const CustomStatsWidget = dynamic(() => import("@/components/dashboard/widgets/CustomStatsWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 사용자 커스텀 카드 위젯
const CustomMetricWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> ...</div>,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
interface CanvasElementProps {
@ -758,7 +802,7 @@ export function CanvasElement({
<div
ref={elementRef}
data-element-id={element.id}
className={`absolute min-h-[120px] min-w-[120px] cursor-move overflow-hidden rounded-lg border-2 bg-background shadow-lg ${isSelected ? "border-primary ring-2 ring-primary/20" : "border-border"} ${isDragging || isResizing ? "transition-none" : "transition-all duration-150"} `}
className={`bg-background absolute min-h-[120px] min-w-[120px] cursor-move overflow-hidden rounded-lg border-2 shadow-lg ${isSelected ? "border-primary ring-primary/20 ring-2" : "border-border"} ${isDragging || isResizing ? "transition-none" : "transition-all duration-150"} `}
style={{
left: displayPosition.x,
top: displayPosition.y,
@ -809,7 +853,7 @@ export function CanvasElement({
)}
{/* 제목 */}
{!element.type || element.type !== "chart" ? (
<span className="text-xs font-bold text-foreground">{element.customTitle || element.title}</span>
<span className="text-foreground text-xs font-bold">{element.customTitle || element.title}</span>
) : null}
</div>
<div className="flex gap-1">
@ -817,7 +861,7 @@ export function CanvasElement({
<Button
variant="ghost"
size="icon"
className="element-close hover:bg-destructive h-5 w-5 text-muted-foreground hover:text-white"
className="element-close hover:bg-destructive text-muted-foreground h-5 w-5 hover:text-white"
onClick={handleRemove}
onMouseDown={(e) => e.stopPropagation()}
title="삭제"
@ -831,9 +875,9 @@ export function CanvasElement({
<div className="relative h-[calc(100%-32px)] px-2 pb-2">
{element.type === "chart" ? (
// 차트 렌더링
<div className="h-full w-full bg-background">
<div className="bg-background h-full w-full">
{isLoadingData ? (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
<div className="text-muted-foreground flex h-full w-full items-center justify-center">
<div className="text-center">
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
<div className="text-sm"> ...</div>
@ -921,7 +965,12 @@ export function CanvasElement({
) : element.type === "widget" && element.subtype === "status-summary" ? (
// 커스텀 상태 카드 - 범용 위젯
<div className="widget-interactive-area h-full w-full">
<StatusSummaryWidget element={element} title="상태 요약" icon="📊" bgGradient="from-background to-primary/10" />
<StatusSummaryWidget
element={element}
title="상태 요약"
icon="📊"
bgGradient="from-background to-primary/10"
/>
</div>
) : /* element.type === "widget" && element.subtype === "list-summary" ? (
// 커스텀 목록 카드 - 범용 위젯 (다른 분 작업 중 - 임시 주석)
@ -1106,7 +1155,7 @@ function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) {
return (
<div
className={`resize-handle absolute h-3 w-3 border border-white bg-success ${getPositionClass()} `}
className={`resize-handle bg-success absolute h-3 w-3 border border-white ${getPositionClass()} `}
onMouseDown={(e) => onMouseDown(e, position)}
/>
);

View File

@ -6,10 +6,10 @@ import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
Alert
Alert
AlertDialogHeader,
Alert
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
} from "@/components/ui/alert-dialog";
import { Loader2 } from "lucide-react";

View File

@ -2,13 +2,13 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
DialogHeader,
DialogFooter,
} from "@/components/ui/dialog";
ResizableDialog,
ResizableDialogContent,
ResizableDialogHeader,
ResizableDialogTitle,
ResizableDialogDescription,
ResizableDialogFooter,
} from "@/components/ui/resizable-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -101,17 +101,17 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
<ResizableDialogContent className="sm:max-w-[500px]">
<ResizableDialogHeader>
<ResizableDialogTitle className="flex items-center gap-2">
<Copy className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
</ResizableDialogTitle>
<ResizableDialogDescription>
{sourceScreen?.screenName} . .
</DialogDescription>
</DialogHeader>
</ResizableDialogDescription>
</ResizableDialogHeader>
<div className="space-y-4">
{/* 원본 화면 정보 */}
@ -185,8 +185,8 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ResizableDialogFooter>
</ResizableDialogContent>
</ResizableDialog>
);
}

View File

@ -4,11 +4,7 @@ import React, { useState, useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
DialogHeader,
DialogFooter,
} from "@/components/ui/dialog";
} from "@/components/ui/resizable-dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -550,7 +546,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
)}
</div>
<DialogFooter className="flex gap-2">
<ResizableDialogFooter className="flex gap-2">
<Button variant="outline" onClick={handleAssignLater} disabled={assigning}>
<X className="mr-2 h-4 w-4" />
@ -628,7 +624,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
</div>
</div>
<DialogFooter className="flex gap-2">
<ResizableDialogFooter className="flex gap-2">
<Button variant="outline" onClick={() => setShowReplaceDialog(false)} disabled={assigning}>
</Button>

View File

@ -269,9 +269,9 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
return `${actualHeight}px`;
}
// 1순위: style.height가 있으면 우선 사용
// 1순위: style.height가 있으면 우선 사용 (문자열 그대로 또는 숫자+px)
if (componentStyle?.height) {
return componentStyle.height;
return typeof componentStyle.height === "number" ? `${componentStyle.height}px` : componentStyle.height;
}
// 2순위: size.height (픽셀)
@ -283,6 +283,20 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
return `${size?.height || 10}px`;
};
// layout 타입 컴포넌트인지 확인
const isLayoutComponent = component.type === "layout" || (component.componentConfig as any)?.type?.includes("layout");
// layout 컴포넌트는 component 객체에 style.height 추가
const enhancedComponent = isLayoutComponent
? {
...component,
style: {
...component.style,
height: getHeight(),
},
}
: component;
const baseStyle = {
left: `${position.x}px`,
top: `${position.y}px`,
@ -325,7 +339,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
},
"4. 너비 비교": {
"외부 / 내부": `${outerRect.width}px / ${innerRect.width}px`,
"비율": `${((innerRect.width / outerRect.width) * 100).toFixed(2)}%`,
: `${((innerRect.width / outerRect.width) * 100).toFixed(2)}%`,
},
});
}
@ -376,7 +390,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
style={{ width: "100%", maxWidth: "100%" }}
>
<DynamicComponentRenderer
component={component}
component={enhancedComponent}
isSelected={isSelected}
isDesignMode={isDesignMode}
isInteractive={!isDesignMode} // 편집 모드가 아닐 때만 인터랙티브

View File

@ -26,15 +26,12 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
ResizableDialog,
ResizableDialogContent,
ResizableDialogHeader
} from "@/components/ui/dialog";
ResizableDialogHeader,
ResizableDialogTitle,
ResizableDialogFooter,
} from "@/components/ui/resizable-dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash } from "lucide-react";
import { ScreenDefinition } from "@/types/screen";
@ -459,7 +456,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
}`}
onClick={() => onDesignScreen(screen)}
>
<TableCell className="h-16 px-6 py-3 cursor-pointer">
<TableCell className="h-16 cursor-pointer px-6 py-3">
<div>
<div className="font-medium">{screen.screenName}</div>
{screen.description && (
@ -699,7 +696,10 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
</TableHeader>
<TableBody>
{deletedScreens.map((screen) => (
<TableRow key={screen.screenId} className="bg-background hover:bg-muted/50 border-b transition-colors">
<TableRow
key={screen.screenId}
className="bg-background hover:bg-muted/50 border-b transition-colors"
>
<TableCell className="h-16 px-6 py-3">
<Checkbox
checked={selectedScreenIds.includes(screen.screenId)}
@ -1065,11 +1065,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
</AlertDialog>
{/* 화면 편집 다이얼로그 */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<ResizableDialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<ResizableDialogContent className="sm:max-w-[500px]">
<ResizableDialogHeader>
<ResizableDialogTitle> </ResizableDialogTitle>
</ResizableDialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="edit-screenName"> *</Label>
@ -1106,23 +1106,23 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
</Select>
</div>
</div>
<DialogFooter>
<ResizableDialogFooter>
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
</Button>
<Button onClick={handleEditSave} disabled={!editFormData.screenName.trim()}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ResizableDialogFooter>
</ResizableDialogContent>
</ResizableDialog>
{/* 화면 미리보기 다이얼로그 */}
<Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
<DialogContent className="h-[95vh] max-w-[95vw]">
<DialogHeader>
<DialogTitle> - {screenToPreview?.screenName}</DialogTitle>
</DialogHeader>
<ResizableDialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
<ResizableDialogContent className="h-[95vh] max-w-[95vw]">
<ResizableDialogHeader>
<ResizableDialogTitle> - {screenToPreview?.screenName}</ResizableDialogTitle>
</ResizableDialogHeader>
<div className="flex flex-1 items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-slate-100 p-6">
{isLoadingPreview ? (
<div className="flex h-full items-center justify-center">
@ -1272,7 +1272,8 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
// 버튼 타입일 때 디버깅 (widget 타입 또는 component 타입 모두 체크)
if (
(component.type === "widget" && (component as any).widgetType === "button") ||
(component.type === "component" && (component as any).componentType?.includes("button"))
(component.type === "component" &&
(component as any).componentType?.includes("button"))
) {
console.log("🔘 ScreenList 버튼 외부 div 스타일:", {
id: component.id,
@ -1360,7 +1361,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
</div>
)}
</div>
<DialogFooter>
<ResizableDialogFooter>
<Button variant="outline" onClick={() => setPreviewDialogOpen(false)}>
</Button>
@ -1368,9 +1369,9 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
<Palette className="mr-2 h-4 w-4" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ResizableDialogFooter>
</ResizableDialogContent>
</ResizableDialog>
</div>
);
}

View File

@ -18,7 +18,7 @@ export const AUTH_CONFIG = {
export const UI_CONFIG = {
COMPANY_NAME: "VEXPLOR",
COPYRIGHT: "© 2024 VEXPLOR. All rights reserved.",
POWERED_BY: "Powered by Vexolor",
POWERED_BY: "Powered by Vexplor",
} as const;
export const FORM_VALIDATION = {

View File

@ -42,6 +42,7 @@ export const uploadFiles = async (params: {
autoLink?: boolean;
columnName?: string;
isVirtualFileColumn?: boolean;
companyCode?: string; // 🔒 멀티테넌시: 회사 코드
}): Promise<FileUploadResponse> => {
const formData = new FormData();
@ -64,6 +65,7 @@ export const uploadFiles = async (params: {
if (params.autoLink !== undefined) formData.append("autoLink", params.autoLink.toString());
if (params.columnName) formData.append("columnName", params.columnName);
if (params.isVirtualFileColumn !== undefined) formData.append("isVirtualFileColumn", params.isVirtualFileColumn.toString());
if (params.companyCode) formData.append("companyCode", params.companyCode); // 🔒 멀티테넌시
const response = await apiClient.post("/files/upload", formData, {
headers: {
@ -247,3 +249,19 @@ export const getDirectFileUrl = (filePath: string): string => {
const baseUrl = process.env.NEXT_PUBLIC_API_URL?.replace("/api", "") || "";
return `${baseUrl}${filePath}`;
};
/**
*
*/
export const setRepresentativeFile = async (objid: string): Promise<{
success: boolean;
message: string;
}> => {
try {
const response = await apiClient.put(`/files/representative/${objid}`);
return response.data;
} catch (error) {
console.error("대표 파일 설정 오류:", error);
throw new Error("대표 파일 설정에 실패했습니다.");
}
};

View File

@ -297,6 +297,20 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 렌더러 props 구성
// component.style에서 height 제거 (RealtimePreviewDynamic에서 size.height로 처리)
// 단, layout 타입 컴포넌트(split-panel-layout 등)는 height 유지
const isLayoutComponent =
component.type === "layout" ||
componentType === "split-panel-layout" ||
componentType?.includes("layout");
console.log("🔍 [DynamicComponentRenderer] 높이 처리:", {
componentId: component.id,
componentType,
isLayoutComponent,
hasHeight: !!component.style?.height,
height: component.style?.height
});
const { height: _height, ...styleWithoutHeight } = component.style || {};
// 숨김 값 추출
@ -310,7 +324,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onDragEnd,
size: component.size || newComponent.defaultSize,
position: component.position,
style: styleWithoutHeight,
style: isLayoutComponent ? component.style : styleWithoutHeight, // 레이아웃은 height 유지
config: component.componentConfig,
componentConfig: component.componentConfig,
value: currentValue, // formData에서 추출한 현재 값 전달

View File

@ -17,7 +17,8 @@ import {
Music,
Archive,
Presentation,
X
X,
Star
} from "lucide-react";
import { formatFileSize } from "@/lib/utils";
import { FileViewerModal } from "./FileViewerModal";
@ -30,6 +31,7 @@ interface FileManagerModalProps {
onFileDownload: (file: FileInfo) => void;
onFileDelete: (file: FileInfo) => void;
onFileView: (file: FileInfo) => void;
onSetRepresentative?: (file: FileInfo) => void; // 대표 이미지 설정 콜백
config: FileUploadConfig;
isDesignMode?: boolean;
}
@ -42,6 +44,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
onFileDownload,
onFileDelete,
onFileView,
onSetRepresentative,
config,
isDesignMode = false,
}) => {
@ -49,6 +52,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
const [uploading, setUploading] = useState(false);
const [viewerFile, setViewerFile] = useState<FileInfo | null>(null);
const [isViewerOpen, setIsViewerOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<FileInfo | null>(null); // 선택된 파일 (좌측 미리보기용)
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null); // 이미지 미리보기 URL
const fileInputRef = useRef<HTMLInputElement>(null);
// 파일 아이콘 가져오기
@ -138,10 +143,49 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
setViewerFile(null);
};
// 파일 클릭 시 미리보기 로드
const handleFileClick = async (file: FileInfo) => {
setSelectedFile(file);
// 이미지 파일인 경우 미리보기 로드
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
if (imageExtensions.includes(file.fileExt.toLowerCase())) {
try {
// 이전 Blob URL 해제
if (previewImageUrl) {
URL.revokeObjectURL(previewImageUrl);
}
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/files/preview/${file.objid}`, {
responseType: 'blob'
});
const blob = new Blob([response.data]);
const blobUrl = URL.createObjectURL(blob);
setPreviewImageUrl(blobUrl);
} catch (error) {
console.error("이미지 로드 실패:", error);
setPreviewImageUrl(null);
}
} else {
setPreviewImageUrl(null);
}
};
// 컴포넌트 언마운트 시 Blob URL 해제
React.useEffect(() => {
return () => {
if (previewImageUrl) {
URL.revokeObjectURL(previewImageUrl);
}
};
}, [previewImageUrl]);
return (
<>
<Dialog open={isOpen} onOpenChange={() => {}}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden [&>button]:hidden">
<DialogContent className="max-w-6xl max-h-[90vh] 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})
@ -157,17 +201,21 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
</Button>
</DialogHeader>
<div className="flex flex-col space-y-4 h-[70vh]">
{/* 파일 업로드 영역 */}
<div className="flex flex-col space-y-3 h-[75vh]">
{/* 파일 업로드 영역 - 높이 축소 */}
{!isDesignMode && (
<div
className={`
border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors
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={handleFileSelect}
onClick={() => {
if (!config.disabled && !isDesignMode) {
fileInputRef.current?.click();
}
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@ -183,30 +231,48 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
/>
{uploading ? (
<div className="flex flex-col items-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mb-3"></div>
<span className="text-blue-600 font-medium"> ...</span>
<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>
) : (
<div className="flex flex-col items-center">
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<p className="text-lg font-medium text-gray-900 mb-2">
<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 text-gray-500">
{config.accept && `지원 형식: ${config.accept}`}
{config.maxSize && ` • 최대 ${formatFileSize(config.maxSize)}`}
{config.multiple && ' • 여러 파일 선택 가능'}
</p>
</div>
)}
</div>
)}
{/* 파일 목록 */}
<div className="flex-1 overflow-y-auto border border-gray-200 rounded-lg">
<div className="p-4">
<div className="flex items-center justify-between mb-4">
{/* 좌우 분할 레이아웃 */}
<div className="flex-1 flex gap-4 min-h-0">
{/* 좌측: 이미지 미리보기 */}
<div className="w-1/2 border border-gray-200 rounded-lg bg-gray-50 flex items-center justify-center overflow-hidden">
{selectedFile && previewImageUrl ? (
<img
src={previewImageUrl}
alt={selectedFile.realFileName}
className="max-w-full max-h-full object-contain"
/>
) : selectedFile ? (
<div className="flex flex-col items-center text-gray-400">
{getFileIcon(selectedFile.fileExt)}
<p className="mt-2 text-sm"> </p>
</div>
) : (
<div className="flex flex-col items-center text-gray-400">
<ImageIcon className="w-16 h-16 mb-2" />
<p className="text-sm"> </p>
</div>
)}
</div>
{/* 우측: 파일 목록 */}
<div className="w-1/2 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 items-center justify-between">
<h3 className="text-sm font-medium text-gray-700">
</h3>
@ -216,53 +282,89 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
</Badge>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto p-3">
{uploadedFiles.length > 0 ? (
<div className="space-y-2">
{uploadedFiles.map((file) => (
<div
key={file.objid}
className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
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'}
`}
onClick={() => handleFileClick(file)}
>
<div className="flex-shrink-0">
{getFileIcon(file.fileExt)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 truncate">
{file.realFileName}
</p>
</span>
{file.isRepresentative && (
<Badge variant="default" className="h-5 px-1.5 text-xs">
</Badge>
)}
</div>
<p className="text-xs text-gray-500">
{formatFileSize(file.fileSize)} {file.fileExt.toUpperCase()}
</p>
</div>
<div className="flex items-center space-x-1">
{onSetRepresentative && (
<Button
variant={file.isRepresentative ? "default" : "ghost"}
size="sm"
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
onSetRepresentative(file);
}}
title={file.isRepresentative ? "현재 대표 파일" : "대표 파일로 설정"}
>
<Star className={`w-3 h-3 ${file.isRepresentative ? "fill-white" : ""}`} />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleFileViewInternal(file)}
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
handleFileViewInternal(file);
}}
title="미리보기"
>
<Eye className="w-4 h-4" />
<Eye className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => onFileDownload(file)}
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
onFileDownload(file);
}}
title="다운로드"
>
<Download className="w-4 h-4" />
<Download className="w-3 h-3" />
</Button>
{!isDesignMode && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-red-500 hover:text-red-700"
onClick={() => onFileDelete(file)}
className="h-7 w-7 p-0 text-red-500 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
onFileDelete(file);
}}
title="삭제"
>
<Trash2 className="w-4 h-4" />
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
@ -270,17 +372,18 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
<File className="w-16 h-16 mb-4 text-gray-300" />
<p className="text-lg font-medium text-gray-600"> </p>
<p className="text-sm text-gray-500 mt-2">
{isDesignMode ? '디자인 모드에서는 파일을 업로드할 수 없습니다' : '위의 영역에 파일을 드래그하거나 클릭하여 업로드하세요'}
<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" />
<p className="text-sm font-medium text-gray-600"> </p>
<p className="text-xs text-gray-500 mt-1">
{isDesignMode ? '디자인 모드에서는 파일을 업로드할 수 없습니다' : '위의 영역에 파일을 업로드하세요'}
</p>
</div>
)}
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>

View File

@ -6,6 +6,7 @@ import { toast } from "sonner";
import { uploadFiles, downloadFile, deleteFile, getComponentFiles } from "@/lib/api/file";
import { GlobalFileManager } from "@/lib/api/globalFile";
import { formatFileSize } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { FileViewerModal } from "./FileViewerModal";
import { FileManagerModal } from "./FileManagerModal";
import { FileUploadConfig, FileInfo, FileUploadStatus, FileUploadResponse } from "./types";
@ -98,6 +99,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const [isViewerOpen, setIsViewerOpen] = useState(false);
const [isFileManagerOpen, setIsFileManagerOpen] = useState(false);
const [forceUpdate, setForceUpdate] = useState(0);
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// 컴포넌트 마운트 시 즉시 localStorage에서 파일 복원
@ -146,12 +148,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 현재 컴포넌트와 일치하고 화면설계 모드에서 온 이벤트인 경우
if (event.detail.componentId === component.id && event.detail.source === "designMode") {
console.log("✅✅✅ 화면설계 모드 → 실제 화면 파일 동기화 시작:", {
componentId: component.id,
filesCount: event.detail.files?.length || 0,
action: event.detail.action,
});
// 파일 상태 업데이트
const newFiles = event.detail.files || [];
setUploadedFiles(newFiles);
@ -202,24 +198,35 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 템플릿 파일과 데이터 파일을 조회하는 함수
const loadComponentFiles = useCallback(async () => {
if (!component?.id) return;
if (!component?.id) return false;
try {
let screenId =
formData?.screenId ||
(typeof window !== "undefined" && window.location.pathname.includes("/screens/")
? parseInt(window.location.pathname.split("/screens/")[1])
: null);
// 1. formData에서 screenId 가져오기
let screenId = formData?.screenId;
// 디자인 모드인 경우 기본 화면 ID 사용
if (!screenId && isDesignMode) {
screenId = 40; // 기본 화면 ID
console.log("📂 디자인 모드: 기본 화면 ID 사용 (40)");
// 2. URL에서 screenId 추출 (/screens/:id 패턴)
if (!screenId && typeof window !== "undefined") {
const pathname = window.location.pathname;
const screenMatch = pathname.match(/\/screens\/(\d+)/);
if (screenMatch) {
screenId = parseInt(screenMatch[1]);
}
}
// 3. 디자인 모드인 경우 임시 화면 ID 사용
if (!screenId && isDesignMode) {
screenId = 999999; // 디자인 모드 임시 ID
}
// 4. 화면 ID가 없으면 컴포넌트 ID만으로 조회 시도
if (!screenId) {
console.log("📂 화면 ID 없음, 기존 파일 로직 사용");
return false; // 기존 로직 사용
console.warn("⚠️ 화면 ID 없음, 컴포넌트 ID만으로 파일 조회:", {
componentId: component.id,
pathname: window.location.pathname,
formData: formData,
});
// screenId를 0으로 설정하여 컴포넌트 ID로만 조회
screenId = 0;
}
const params = {
@ -227,21 +234,12 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
componentId: component.id,
tableName: formData?.tableName || component.tableName,
recordId: formData?.id,
columnName: component.columnName,
columnName: component.columnName || component.id, // 🔑 columnName이 없으면 component.id 사용
};
console.log("📂 컴포넌트 파일 조회:", params);
const response = await getComponentFiles(params);
if (response.success) {
console.log("📁 파일 조회 결과:", {
templateFiles: response.templateFiles.length,
dataFiles: response.dataFiles.length,
totalFiles: response.totalFiles.length,
summary: response.summary,
actualFiles: response.totalFiles,
});
// 파일 데이터 형식 통일
const formattedFiles = response.totalFiles.map((file: any) => ({
@ -256,7 +254,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
...file,
}));
console.log("📁 형식 변환된 파일 데이터:", formattedFiles);
// 🔄 localStorage의 기존 파일과 서버 파일 병합
let finalFiles = formattedFiles;
@ -272,13 +269,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
finalFiles = [...formattedFiles, ...additionalFiles];
console.log("🔄 파일 병합 완료:", {
서버파일: formattedFiles.length,
로컬파일: parsedBackupFiles.length,
추가파일: additionalFiles.length,
최종파일: finalFiles.length,
최종파일목록: finalFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
});
}
} catch (e) {
console.warn("파일 병합 중 오류:", e);
@ -304,7 +294,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
try {
const backupKey = `fileUpload_${component.id}`;
localStorage.setItem(backupKey, JSON.stringify(finalFiles));
console.log("💾 localStorage 백업 업데이트 완료:", finalFiles.length);
} catch (e) {
console.warn("localStorage 백업 업데이트 실패:", e);
}
@ -317,7 +306,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
return false; // 기존 로직 사용
}, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id]);
// 컴포넌트 파일 동기화
// 컴포넌트 파일 동기화 (DB 우선, localStorage는 보조)
useEffect(() => {
const componentFiles = (component as any)?.uploadedFiles || [];
const lastUpdate = (component as any)?.lastFileUpdate;
@ -330,15 +319,13 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
currentUploadedFiles: uploadedFiles.length,
});
// 먼저 새로운 템플릿 파일 조회 시도
loadComponentFiles().then((useNewLogic) => {
if (useNewLogic) {
console.log("✅ 새로운 템플릿 파일 로직 사용");
return; // 새로운 로직이 성공했으면 기존 로직 스킵
// 🔒 항상 DB에서 최신 파일 목록을 조회 (멀티테넌시 격리)
loadComponentFiles().then((dbLoadSuccess) => {
if (dbLoadSuccess) {
return; // DB 로드 성공 시 localStorage 무시
}
// 기존 로직 사용
console.log("📂 기존 파일 로직 사용");
// DB 로드 실패 시에만 기존 로직 사용 (하위 호환성)
// 전역 상태에서 최신 파일 정보 가져오기
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
@ -347,51 +334,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
console.log("🔄 FileUploadComponent 파일 동기화:", {
componentId: component.id,
componentFiles: componentFiles.length,
globalFiles: globalFiles.length,
currentFiles: currentFiles.length,
uploadedFiles: uploadedFiles.length,
lastUpdate: lastUpdate,
});
// localStorage에서 백업 파일 복원 (새로고침 시 중요!)
try {
const backupKey = `fileUpload_${component.id}`;
const backupFiles = localStorage.getItem(backupKey);
if (backupFiles) {
const parsedFiles = JSON.parse(backupFiles);
if (parsedFiles.length > 0 && currentFiles.length === 0) {
console.log("🔄 localStorage에서 파일 복원:", {
componentId: component.id,
restoredFiles: parsedFiles.length,
files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
});
setUploadedFiles(parsedFiles);
// 전역 상태에도 복원
if (typeof window !== "undefined") {
(window as any).globalFileState = {
...(window as any).globalFileState,
[component.id]: parsedFiles,
};
}
return;
}
}
} catch (e) {
console.warn("localStorage 백업 복원 실패:", e);
}
// 최신 파일과 현재 파일 비교
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
console.log("🔄 useEffect에서 파일 목록 변경 감지:", {
currentFiles: currentFiles.length,
uploadedFiles: uploadedFiles.length,
currentFilesData: currentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
uploadedFilesData: uploadedFiles.map((f) => ({ objid: f.objid, name: f.realFileName })),
});
setUploadedFiles(currentFiles);
setForceUpdate((prev) => prev + 1);
}
@ -489,28 +434,15 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const duplicates: string[] = [];
const uniqueFiles: File[] = [];
console.log("🔍 중복 파일 체크:", {
uploadedFiles: uploadedFiles.length,
existingFileNames: existingFileNames,
newFiles: files.map((f) => f.name.toLowerCase()),
});
files.forEach((file) => {
const fileName = file.name.toLowerCase();
if (existingFileNames.includes(fileName)) {
duplicates.push(file.name);
console.log("❌ 중복 파일 발견:", file.name);
} else {
uniqueFiles.push(file);
console.log("✅ 새로운 파일:", file.name);
}
});
console.log("🔍 중복 체크 결과:", {
duplicates: duplicates,
uniqueFiles: uniqueFiles.map((f) => f.name),
});
if (duplicates.length > 0) {
toast.error(`중복된 파일이 있습니다: ${duplicates.join(", ")}`, {
description: "같은 이름의 파일이 이미 업로드되어 있습니다.",
@ -533,24 +465,38 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// targetObjid 생성 - 템플릿 vs 데이터 파일 구분
const tableName = formData?.tableName || component.tableName || "default_table";
const recordId = formData?.id;
const screenId = formData?.screenId;
const columnName = component.columnName || component.id;
// screenId 추출 (우선순위: formData > URL)
let screenId = formData?.screenId;
if (!screenId && typeof window !== "undefined") {
const pathname = window.location.pathname;
const screenMatch = pathname.match(/\/screens\/(\d+)/);
if (screenMatch) {
screenId = parseInt(screenMatch[1]);
}
}
let targetObjid;
if (recordId && tableName) {
// 실제 데이터 파일
// 우선순위: 1) 실제 데이터 (recordId가 숫자/문자열이고 temp_가 아님) > 2) 템플릿 (screenId) > 3) 기본값
const isRealRecord = recordId && typeof recordId !== 'undefined' && !String(recordId).startsWith('temp_');
if (isRealRecord && tableName) {
// 실제 데이터 파일 (진짜 레코드 ID가 있을 때만)
targetObjid = `${tableName}:${recordId}:${columnName}`;
console.log("📁 실제 데이터 파일 업로드:", targetObjid);
} else if (screenId) {
// 템플릿 파일
targetObjid = `screen_${screenId}:${component.id}`;
console.log("🎨 템플릿 파일 업로드:", targetObjid);
// 🔑 템플릿 파일 (백엔드 조회 형식과 동일하게)
targetObjid = `screen_files:${screenId}:${component.id}:${columnName}`;
} else {
// 기본값 (화면관리에서 사용)
targetObjid = `temp_${component.id}`;
console.log("📝 기본 파일 업로드:", targetObjid);
}
// 🔒 현재 사용자의 회사 코드 가져오기 (멀티테넌시 격리)
const userCompanyCode = (window as any).__user__?.companyCode;
const uploadData = {
// 🎯 formData에서 백엔드 API 설정 가져오기
autoLink: formData?.autoLink || true,
@ -560,36 +506,23 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
isVirtualFileColumn: formData?.isVirtualFileColumn || true,
docType: component.fileConfig?.docType || "DOCUMENT",
docTypeName: component.fileConfig?.docTypeName || "일반 문서",
companyCode: userCompanyCode, // 🔒 멀티테넌시: 회사 코드 명시적 전달
// 호환성을 위한 기존 필드들
tableName: tableName,
fieldName: columnName,
targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가
};
console.log("📤 파일 업로드 시작:", {
originalFiles: files.length,
filesToUpload: filesToUpload.length,
files: filesToUpload.map((f) => ({ name: f.name, size: f.size })),
uploadData,
});
const response = await uploadFiles({
files: filesToUpload,
...uploadData,
});
console.log("📤 파일 업로드 API 응답:", response);
if (response.success) {
// FileUploadResponse 타입에 맞게 files 배열 사용
const fileData = response.files || (response as any).data || [];
console.log("📁 파일 데이터 확인:", {
hasFiles: !!response.files,
hasData: !!(response as any).data,
fileDataLength: fileData.length,
fileData: fileData,
responseKeys: Object.keys(response),
});
if (fileData.length === 0) {
throw new Error("업로드된 파일 데이터를 받지 못했습니다.");
@ -614,15 +547,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
...file,
}));
console.log("📁 변환된 파일 데이터:", newFiles);
const updatedFiles = [...uploadedFiles, ...newFiles];
console.log("🔄 파일 상태 업데이트:", {
이전파일수: uploadedFiles.length,
새파일수: newFiles.length,
총파일수: updatedFiles.length,
updatedFiles: updatedFiles.map((f) => ({ objid: f.objid, name: f.realFileName })),
});
setUploadedFiles(updatedFiles);
setUploadStatus("success");
@ -832,6 +758,96 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
[uploadedFiles, onUpdate, component.id],
);
// 대표 이미지 Blob URL 로드
const loadRepresentativeImage = useCallback(
async (file: FileInfo) => {
try {
const isImage = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(
file.fileExt.toLowerCase().replace(".", "")
);
if (!isImage) {
setRepresentativeImageUrl(null);
return;
}
console.log("🖼️ 대표 이미지 로드 시작:", file.realFileName);
// API 클라이언트를 통해 Blob으로 다운로드 (인증 토큰 포함)
const response = await apiClient.get(`/files/download/${file.objid}`, {
params: {
serverFilename: file.savedFileName,
},
responseType: "blob",
});
// Blob URL 생성
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
// 이전 URL 정리
if (representativeImageUrl) {
window.URL.revokeObjectURL(representativeImageUrl);
}
setRepresentativeImageUrl(url);
console.log("✅ 대표 이미지 로드 성공:", url);
} catch (error) {
console.error("❌ 대표 이미지 로드 실패:", error);
setRepresentativeImageUrl(null);
}
},
[representativeImageUrl],
);
// 대표 이미지 설정 핸들러
const handleSetRepresentative = useCallback(
async (file: FileInfo) => {
try {
// API 호출하여 DB에 대표 파일 설정
const { setRepresentativeFile } = await import("@/lib/api/file");
await setRepresentativeFile(file.objid);
// 상태 업데이트
const updatedFiles = uploadedFiles.map((f) => ({
...f,
isRepresentative: f.objid === file.objid,
}));
setUploadedFiles(updatedFiles);
// 대표 이미지 로드
loadRepresentativeImage(file);
console.log("✅ 대표 파일 설정 완료:", {
componentId: component.id,
representativeFile: file.realFileName,
objid: file.objid,
});
} catch (e) {
console.error("❌ 대표 파일 설정 실패:", e);
}
},
[uploadedFiles, component.id, loadRepresentativeImage]
);
// uploadedFiles 변경 시 대표 이미지 로드
useEffect(() => {
const representativeFile = uploadedFiles.find(f => f.isRepresentative) || uploadedFiles[0];
if (representativeFile) {
loadRepresentativeImage(representativeFile);
} else {
setRepresentativeImageUrl(null);
}
// 컴포넌트 언마운트 시 Blob URL 정리
return () => {
if (representativeImageUrl) {
window.URL.revokeObjectURL(representativeImageUrl);
}
};
}, [uploadedFiles]);
// 드래그 앤 드롭 핸들러
const handleDragOver = useCallback(
(e: React.DragEvent) => {
@ -901,6 +917,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
<div
style={{
...componentStyle,
width: "100%", // 🆕 부모 컨테이너 너비에 맞춤
height: "100%", // 🆕 부모 컨테이너 높이에 맞춤
border: "none !important",
boxShadow: "none !important",
outline: "none !important",
@ -911,8 +929,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}}
className={`${className} file-upload-container`}
>
{/* 라벨 렌더링 - 주석처리 */}
{/* {component.label && component.style?.labelDisplay !== false && (
{/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && (
<label
style={{
position: "absolute",
@ -934,148 +952,71 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
<span style={{ color: "#ef4444" }}>*</span>
)}
</label>
)} */}
)}
<div
className="border-border bg-card flex h-full w-full flex-col space-y-3 rounded-lg border p-3 transition-all duration-200 hover:shadow-sm"
style={{ minHeight: "140px" }}
className="border-border bg-card relative flex h-full w-full flex-col rounded-lg border overflow-hidden"
>
{/* 파일 업로드 영역 - 주석처리 */}
{/* {!isDesignMode && (
<div
className={`
border border-dashed rounded p-2 text-center cursor-pointer transition-colors
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
${safeComponentConfig.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
${uploadStatus === 'uploading' ? 'opacity-75' : ''}
`}
style={{ minHeight: '50px' }}
onClick={handleClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<input
ref={fileInputRef}
type="file"
multiple={safeComponentConfig.multiple}
accept={safeComponentConfig.accept}
onChange={handleInputChange}
className="hidden"
disabled={safeComponentConfig.disabled}
{/* 대표 이미지 전체 화면 표시 */}
{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">
<img
src={representativeImageUrl}
alt={representativeFile.realFileName}
className="h-full w-full object-contain"
/>
{uploadStatus === 'uploading' ? (
<div className="flex flex-col items-center space-y-2">
<div className="flex items-center space-x-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="text-blue-600 font-medium"> ...</span>
</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>
) : (
<>
<div>
<Upload className="mx-auto h-6 w-6 text-gray-400 mb-2" />
<p className="text-xs font-medium text-gray-600">
<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>
<Badge variant="secondary" className="mt-2">
</Badge>
</div>
)}
{/* 우측 하단 자세히보기 버튼 */}
<div className="absolute bottom-3 right-3">
<Button
variant="secondary"
size="sm"
className="h-8 px-3 text-xs shadow-md"
onClick={() => setIsFileManagerOpen(true)}
>
({uploadedFiles.length})
</Button>
</div>
</>
)}
</div>
)} */}
{/* 업로드된 파일 목록 - 항상 표시 */}
{(() => {
const shouldShow = true; // 항상 표시하도록 강제
console.log("🎯🎯🎯 파일 목록 렌더링 조건 체크:", {
uploadedFilesLength: uploadedFiles.length,
isDesignMode: isDesignMode,
shouldShow: shouldShow,
uploadedFiles: uploadedFiles.map((f) => ({ objid: f.objid, name: f.realFileName })),
"🚨 렌더링 여부": shouldShow ? "✅ 렌더링됨" : "❌ 렌더링 안됨",
});
return shouldShow;
})() && (
<div className="flex-1 overflow-y-auto">
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="flex items-center gap-2 text-sm font-semibold">
<File className="text-primary h-4 w-4" />
({uploadedFiles.length})
</h4>
<div className="flex items-center space-x-2">
{uploadedFiles.length > 0 && (
<Badge variant="secondary" className="h-5 px-1.5 text-xs">
{formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
</Badge>
)}
);
})() : (
<div className="flex h-full w-full flex-col items-center justify-center text-muted-foreground">
<File className="mb-3 h-12 w-12" />
<p className="text-sm font-medium"> </p>
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-xs"
className="mt-4 h-8 px-3 text-xs"
onClick={() => setIsFileManagerOpen(true)}
style={{
boxShadow: "none !important",
textShadow: "none !important",
filter: "none !important",
WebkitBoxShadow: "none !important",
MozBoxShadow: "none !important",
}}
>
</Button>
</div>
</div>
{uploadedFiles.length > 0 ? (
<div className="space-y-1">
{uploadedFiles.map((file) => (
<div
key={file.objid}
className="hover:bg-accent flex items-center space-x-3 rounded p-2 text-sm transition-colors"
style={{ boxShadow: "none", textShadow: "none" }}
>
<div className="flex-shrink-0">{getFileIcon(file.fileExt)}</div>
<span
className="flex-1 cursor-pointer truncate text-gray-900"
onClick={() => handleFileView(file)}
style={{ textShadow: "none" }}
>
{file.realFileName}
</span>
<span className="text-xs text-gray-500" style={{ textShadow: "none" }}>
{formatFileSize(file.fileSize)}
</span>
</div>
))}
<div className="mt-2 text-center text-xs text-gray-500" style={{ textShadow: "none" }}>
💡 "전체 자세히보기"
</div>
</div>
) : (
<div
className="flex flex-col items-center justify-center py-8 text-gray-500"
style={{ textShadow: "none" }}
>
<File className="mb-3 h-12 w-12 text-gray-300" />
<p className="text-sm font-medium" style={{ textShadow: "none" }}>
</p>
<p className="mt-1 text-xs text-gray-400" style={{ textShadow: "none" }}>
</p>
</div>
)}
</div>
</div>
)}
{/* 도움말 텍스트 */}
{safeComponentConfig.helperText && (
<p className="mt-1 text-xs text-gray-500">{safeComponentConfig.helperText}</p>
)}
</div>
@ -1097,6 +1038,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
onFileDownload={handleFileDownload}
onFileDelete={handleFileDelete}
onFileView={handleFileView}
onSetRepresentative={handleSetRepresentative}
config={safeComponentConfig}
isDesignMode={isDesignMode}
/>

View File

@ -30,6 +30,9 @@ export interface FileInfo {
type?: string; // docType과 동일
uploadedAt?: string; // regdate와 동일
_file?: File; // 로컬 파일 객체 (업로드 전)
// 대표 이미지 설정
isRepresentative?: boolean; // 대표 이미지로 설정 여부
}
/**

View File

@ -53,12 +53,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const containerRef = React.useRef<HTMLDivElement>(null);
// 컴포넌트 스타일
// height 처리: 이미 px 단위면 그대로, 숫자면 px 추가
const getHeightValue = () => {
const height = component.style?.height;
if (!height) return "600px";
if (typeof height === "string") return height; // 이미 '540px' 형태
return `${height}px`; // 숫자면 px 추가
};
const componentStyle: React.CSSProperties = isPreview
? {
// 반응형 모드: position relative, 그리드 컨테이너가 제공하는 크기 사용
position: "relative",
// width 제거 - 그리드 컬럼이 결정
height: `${component.style?.height || 600}px`,
width: "100%", // 🆕 부모 컨테이너 너비에 맞춤
height: getHeightValue(),
border: "1px solid #e5e7eb",
}
: {
@ -66,8 +74,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
position: "absolute",
left: `${component.style?.positionX || 0}px`,
top: `${component.style?.positionY || 0}px`,
width: `${component.style?.width || 1000}px`,
height: `${component.style?.height || 600}px`,
width: "100%", // 🆕 부모 컨테이너 너비에 맞춤 (그리드 기반)
height: getHeightValue(),
zIndex: component.style?.positionZ || 1,
cursor: isDesignMode ? "pointer" : "default",
border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb",
@ -257,29 +265,31 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return (
<div
ref={containerRef}
style={
isPreview
style={{
...(isPreview
? {
position: "relative",
height: `${component.style?.height || 600}px`,
border: "1px solid #e5e7eb",
}
: componentStyle
}
: componentStyle),
display: "flex",
flexDirection: "row",
}}
onClick={(e) => {
if (isDesignMode) {
e.stopPropagation();
onClick?.(e);
}
}}
className={`flex overflow-hidden rounded-lg bg-white shadow-sm ${isPreview ? "w-full" : ""}`}
className="w-full overflow-hidden rounded-lg bg-white shadow-sm"
>
{/* 좌측 패널 */}
<div
style={{ width: `${leftWidth}%`, minWidth: isPreview ? "0" : `${minLeftWidth}px` }}
style={{ width: `${leftWidth}%`, minWidth: isPreview ? "0" : `${minLeftWidth}px`, height: "100%" }}
className="border-border flex flex-shrink-0 flex-col border-r"
>
<Card className="flex h-full flex-col border-0 shadow-none">
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
<CardHeader className="border-b pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">
@ -304,9 +314,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
)}
</CardHeader>
<CardContent className="flex-1 overflow-auto">
<CardContent className="flex-1 overflow-auto p-4">
{/* 좌측 데이터 목록 */}
<div className="space-y-1 px-2">
<div className="space-y-1">
{isDesignMode ? (
// 디자인 모드: 샘플 데이터
<>
@ -413,10 +423,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{/* 우측 패널 */}
<div
style={{ width: `${100 - leftWidth}%`, minWidth: isPreview ? "0" : `${minRightWidth}px` }}
style={{ width: `${100 - leftWidth}%`, minWidth: isPreview ? "0" : `${minRightWidth}px`, height: "100%" }}
className="flex flex-shrink-0 flex-col"
>
<Card className="flex h-full flex-col border-0 shadow-none">
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
<CardHeader className="border-b pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">
@ -441,7 +451,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
)}
</CardHeader>
<CardContent className="flex-1 overflow-auto">
<CardContent className="flex-1 overflow-auto p-4">
{/* 우측 데이터 */}
{isLoadingRight ? (
// 로딩 중