Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
commit
e25f8893b0
|
|
@ -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개 파일
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} // 편집 모드가 아닐 때만 인터랙티브
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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("대표 파일 설정에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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에서 추출한 현재 값 전달
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ export interface FileInfo {
|
|||
type?: string; // docType과 동일
|
||||
uploadedAt?: string; // regdate와 동일
|
||||
_file?: File; // 로컬 파일 객체 (업로드 전)
|
||||
|
||||
// 대표 이미지 설정
|
||||
isRepresentative?: boolean; // 대표 이미지로 설정 여부
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
// 로딩 중
|
||||
|
|
|
|||
Loading…
Reference in New Issue