Compare commits
8 Commits
fb201cc799
...
5b79bfb19d
| Author | SHA1 | Date |
|---|---|---|
|
|
5b79bfb19d | |
|
|
03bce9d643 | |
|
|
732928ac0f | |
|
|
35f130061a | |
|
|
920cdccdf9 | |
|
|
0313c83a65 | |
|
|
20e2729bf7 | |
|
|
242e5bee41 |
|
|
@ -63,9 +63,9 @@ export async function mergeCodeAllTables(
|
||||||
);
|
);
|
||||||
|
|
||||||
// 결과 처리 (pool.query 반환 타입 처리)
|
// 결과 처리 (pool.query 반환 타입 처리)
|
||||||
const affectedTables = Array.isArray(result) ? result : (result.rows || []);
|
const affectedTables = Array.isArray(result) ? result : ((result as any).rows || []);
|
||||||
const totalRows = affectedTables.reduce(
|
const totalRows = affectedTables.reduce(
|
||||||
(sum, row) => sum + parseInt(row.rows_updated || 0),
|
(sum: number, row: any) => sum + parseInt(row.rows_updated || 0),
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -148,16 +148,17 @@ export async function getTablesWithColumn(
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await pool.query(query, [columnName]);
|
const result = await pool.query(query, [columnName]);
|
||||||
|
const rows = (result as any).rows || [];
|
||||||
|
|
||||||
logger.info(`컬럼을 가진 테이블 조회 완료: ${result.rows.length}개`);
|
logger.info(`컬럼을 가진 테이블 조회 완료: ${rows.length}개`);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "테이블 목록 조회 성공",
|
message: "테이블 목록 조회 성공",
|
||||||
data: {
|
data: {
|
||||||
columnName,
|
columnName,
|
||||||
tables: result.rows.map((row) => row.table_name),
|
tables: rows.map((row: any) => row.table_name),
|
||||||
count: result.rows.length,
|
count: rows.length,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
@ -223,7 +224,7 @@ export async function previewCodeMerge(
|
||||||
|
|
||||||
// 각 테이블에서 영향받을 행 수 계산
|
// 각 테이블에서 영향받을 행 수 계산
|
||||||
const preview = [];
|
const preview = [];
|
||||||
const tableRows = Array.isArray(tablesResult) ? tablesResult : (tablesResult.rows || []);
|
const tableRows = Array.isArray(tablesResult) ? tablesResult : ((tablesResult as any).rows || []);
|
||||||
|
|
||||||
for (const row of tableRows) {
|
for (const row of tableRows) {
|
||||||
const tableName = row.table_name;
|
const tableName = row.table_name;
|
||||||
|
|
@ -234,7 +235,8 @@ export async function previewCodeMerge(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const countResult = await pool.query(countQuery, [oldValue, companyCode]);
|
const countResult = await pool.query(countQuery, [oldValue, companyCode]);
|
||||||
const count = parseInt(countResult.rows[0].count);
|
const rows = (countResult as any).rows || [];
|
||||||
|
const count = rows.length > 0 ? parseInt(rows[0].count) : 0;
|
||||||
|
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
preview.push({
|
preview.push({
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { Request, Response } from "express";
|
import { Response } from "express";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
import tableCategoryValueService from "../services/tableCategoryValueService";
|
import tableCategoryValueService from "../services/tableCategoryValueService";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블의 카테고리 컬럼 목록 조회
|
* 테이블의 카테고리 컬럼 목록 조회
|
||||||
*/
|
*/
|
||||||
export const getCategoryColumns = async (req: Request, res: Response) => {
|
export const getCategoryColumns = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
const { tableName } = req.params;
|
const { tableName } = req.params;
|
||||||
|
|
@ -32,7 +33,7 @@ export const getCategoryColumns = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 카테고리 값 목록 조회 (메뉴 스코프 적용)
|
* 카테고리 값 목록 조회 (메뉴 스코프 적용)
|
||||||
*/
|
*/
|
||||||
export const getCategoryValues = async (req: Request, res: Response) => {
|
export const getCategoryValues = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
const { tableName, columnName } = req.params;
|
const { tableName, columnName } = req.params;
|
||||||
|
|
@ -62,7 +63,7 @@ export const getCategoryValues = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 카테고리 값 추가
|
* 카테고리 값 추가
|
||||||
*/
|
*/
|
||||||
export const addCategoryValue = async (req: Request, res: Response) => {
|
export const addCategoryValue = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
|
|
@ -91,7 +92,7 @@ export const addCategoryValue = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 카테고리 값 수정
|
* 카테고리 값 수정
|
||||||
*/
|
*/
|
||||||
export const updateCategoryValue = async (req: Request, res: Response) => {
|
export const updateCategoryValue = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
|
|
@ -129,7 +130,7 @@ export const updateCategoryValue = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 카테고리 값 삭제
|
* 카테고리 값 삭제
|
||||||
*/
|
*/
|
||||||
export const deleteCategoryValue = async (req: Request, res: Response) => {
|
export const deleteCategoryValue = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
|
|
@ -166,7 +167,7 @@ export const deleteCategoryValue = async (req: Request, res: Response) => {
|
||||||
* 카테고리 값 일괄 삭제
|
* 카테고리 값 일괄 삭제
|
||||||
*/
|
*/
|
||||||
export const bulkDeleteCategoryValues = async (
|
export const bulkDeleteCategoryValues = async (
|
||||||
req: Request,
|
req: AuthenticatedRequest,
|
||||||
res: Response
|
res: Response
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -204,7 +205,7 @@ export const bulkDeleteCategoryValues = async (
|
||||||
/**
|
/**
|
||||||
* 카테고리 값 순서 변경
|
* 카테고리 값 순서 변경
|
||||||
*/
|
*/
|
||||||
export const reorderCategoryValues = async (req: Request, res: Response) => {
|
export const reorderCategoryValues = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
const { orderedValueIds } = req.body;
|
const { orderedValueIds } = req.body;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import * as tableCategoryValueController from "../controllers/tableCategoryValueController";
|
import {
|
||||||
|
getCategoryColumns,
|
||||||
|
getCategoryValues,
|
||||||
|
addCategoryValue,
|
||||||
|
updateCategoryValue,
|
||||||
|
deleteCategoryValue,
|
||||||
|
bulkDeleteCategoryValues,
|
||||||
|
reorderCategoryValues,
|
||||||
|
} from "../controllers/tableCategoryValueController";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
@ -8,43 +16,25 @@ const router = Router();
|
||||||
router.use(authenticateToken);
|
router.use(authenticateToken);
|
||||||
|
|
||||||
// 테이블의 카테고리 컬럼 목록 조회
|
// 테이블의 카테고리 컬럼 목록 조회
|
||||||
router.get(
|
router.get("/:tableName/columns", getCategoryColumns);
|
||||||
"/:tableName/columns",
|
|
||||||
tableCategoryValueController.getCategoryColumns
|
|
||||||
);
|
|
||||||
|
|
||||||
// 카테고리 값 목록 조회
|
// 카테고리 값 목록 조회
|
||||||
router.get(
|
router.get("/:tableName/:columnName/values", getCategoryValues);
|
||||||
"/:tableName/:columnName/values",
|
|
||||||
tableCategoryValueController.getCategoryValues
|
|
||||||
);
|
|
||||||
|
|
||||||
// 카테고리 값 추가
|
// 카테고리 값 추가
|
||||||
router.post("/values", tableCategoryValueController.addCategoryValue);
|
router.post("/values", addCategoryValue);
|
||||||
|
|
||||||
// 카테고리 값 수정
|
// 카테고리 값 수정
|
||||||
router.put(
|
router.put("/values/:valueId", updateCategoryValue);
|
||||||
"/values/:valueId",
|
|
||||||
tableCategoryValueController.updateCategoryValue
|
|
||||||
);
|
|
||||||
|
|
||||||
// 카테고리 값 삭제
|
// 카테고리 값 삭제
|
||||||
router.delete(
|
router.delete("/values/:valueId", deleteCategoryValue);
|
||||||
"/values/:valueId",
|
|
||||||
tableCategoryValueController.deleteCategoryValue
|
|
||||||
);
|
|
||||||
|
|
||||||
// 카테고리 값 일괄 삭제
|
// 카테고리 값 일괄 삭제
|
||||||
router.post(
|
router.post("/values/bulk-delete", bulkDeleteCategoryValues);
|
||||||
"/values/bulk-delete",
|
|
||||||
tableCategoryValueController.bulkDeleteCategoryValues
|
|
||||||
);
|
|
||||||
|
|
||||||
// 카테고리 값 순서 변경
|
// 카테고리 값 순서 변경
|
||||||
router.post(
|
router.post("/values/reorder", reorderCategoryValues);
|
||||||
"/values/reorder",
|
|
||||||
tableCategoryValueController.reorderCategoryValues
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -404,19 +404,23 @@ export class TableManagementService {
|
||||||
// 🔥 table_type_columns도 업데이트 (멀티테넌시 지원)
|
// 🔥 table_type_columns도 업데이트 (멀티테넌시 지원)
|
||||||
if (settings.inputType) {
|
if (settings.inputType) {
|
||||||
// detailSettings가 문자열이면 파싱, 객체면 그대로 사용
|
// detailSettings가 문자열이면 파싱, 객체면 그대로 사용
|
||||||
let parsedDetailSettings = settings.detailSettings;
|
let parsedDetailSettings: Record<string, any> | undefined = undefined;
|
||||||
if (typeof settings.detailSettings === 'string') {
|
if (settings.detailSettings) {
|
||||||
try {
|
if (typeof settings.detailSettings === 'string') {
|
||||||
parsedDetailSettings = JSON.parse(settings.detailSettings);
|
try {
|
||||||
} catch (e) {
|
parsedDetailSettings = JSON.parse(settings.detailSettings);
|
||||||
logger.warn(`detailSettings 파싱 실패, 그대로 사용: ${settings.detailSettings}`);
|
} catch (e) {
|
||||||
|
logger.warn(`detailSettings 파싱 실패, 그대로 사용: ${settings.detailSettings}`);
|
||||||
|
}
|
||||||
|
} else if (typeof settings.detailSettings === 'object') {
|
||||||
|
parsedDetailSettings = settings.detailSettings as Record<string, any>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.updateColumnInputType(
|
await this.updateColumnInputType(
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
settings.inputType,
|
settings.inputType as string,
|
||||||
companyCode,
|
companyCode,
|
||||||
parsedDetailSettings
|
parsedDetailSettings
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -67,12 +67,6 @@ export const INPUT_TYPE_OPTIONS: InputTypeOption[] = [
|
||||||
description: "단일 선택",
|
description: "단일 선택",
|
||||||
category: "selection",
|
category: "selection",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: "image",
|
|
||||||
label: "이미지",
|
|
||||||
description: "이미지 표시",
|
|
||||||
category: "basic",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 입력 타입 검증 함수
|
// 입력 타입 검증 함수
|
||||||
|
|
|
||||||
|
|
@ -225,12 +225,21 @@ export default function ScreenViewPage() {
|
||||||
const containerWidth = containerRef.current.offsetWidth;
|
const containerWidth = containerRef.current.offsetWidth;
|
||||||
const containerHeight = containerRef.current.offsetHeight;
|
const containerHeight = containerRef.current.offsetHeight;
|
||||||
|
|
||||||
// 가로를 기준으로 스케일 조정 (세로는 스크롤 허용)
|
// 화면이 잘리지 않도록 가로/세로 중 작은 쪽 기준으로 스케일 조정
|
||||||
// ScreenList.tsx와 동일한 방식으로 가로를 꽉 채움
|
|
||||||
const scaleX = containerWidth / designWidth;
|
const scaleX = containerWidth / designWidth;
|
||||||
const scaleY = containerHeight / designHeight;
|
const scaleY = containerHeight / designHeight;
|
||||||
// 가로 기준으로 스케일 설정 (ScreenList와 일관성 유지)
|
// 전체 화면이 보이도록 작은 쪽 기준으로 스케일 설정
|
||||||
const newScale = scaleX;
|
const newScale = Math.min(scaleX, scaleY);
|
||||||
|
|
||||||
|
console.log("📐 스케일 계산:", {
|
||||||
|
containerWidth,
|
||||||
|
containerHeight,
|
||||||
|
designWidth,
|
||||||
|
designHeight,
|
||||||
|
scaleX,
|
||||||
|
scaleY,
|
||||||
|
finalScale: newScale,
|
||||||
|
});
|
||||||
|
|
||||||
setScale(newScale);
|
setScale(newScale);
|
||||||
// 컨테이너 너비 업데이트
|
// 컨테이너 너비 업데이트
|
||||||
|
|
@ -285,7 +294,7 @@ export default function ScreenViewPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenPreviewProvider isPreviewMode={false}>
|
<ScreenPreviewProvider isPreviewMode={false}>
|
||||||
<div ref={containerRef} className="bg-background h-full w-full overflow-hidden">
|
<div ref={containerRef} className="bg-background flex h-full w-full items-center justify-center overflow-hidden">
|
||||||
{/* 레이아웃 준비 중 로딩 표시 */}
|
{/* 레이아웃 준비 중 로딩 표시 */}
|
||||||
{!layoutReady && (
|
{!layoutReady && (
|
||||||
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">
|
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">
|
||||||
|
|
@ -296,17 +305,21 @@ export default function ScreenViewPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 절대 위치 기반 렌더링 */}
|
{/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */}
|
||||||
{layoutReady && layout && layout.components.length > 0 ? (
|
{layoutReady && layout && layout.components.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
className="bg-background relative flex h-full origin-top-left items-start justify-start"
|
className="bg-background relative"
|
||||||
style={{
|
style={{
|
||||||
transform: `scale(${scale})`,
|
|
||||||
transformOrigin: "top left",
|
|
||||||
width: `${screenWidth}px`,
|
width: `${screenWidth}px`,
|
||||||
height: `${screenHeight}px`,
|
height: `${screenHeight}px`,
|
||||||
minWidth: `${screenWidth}px`,
|
minWidth: `${screenWidth}px`,
|
||||||
|
maxWidth: `${screenWidth}px`,
|
||||||
minHeight: `${screenHeight}px`,
|
minHeight: `${screenHeight}px`,
|
||||||
|
maxHeight: `${screenHeight}px`,
|
||||||
|
flexShrink: 0,
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: "center center",
|
||||||
|
overflow: "visible",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 최상위 컴포넌트들 렌더링 */}
|
{/* 최상위 컴포넌트들 렌더링 */}
|
||||||
|
|
|
||||||
|
|
@ -344,7 +344,7 @@ export default function BatchJobModal({
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -564,7 +564,7 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|
|
||||||
|
|
@ -499,7 +499,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="gap-2">
|
<ResizableDialogFooter className="gap-2">
|
||||||
{step !== "basic" && !generationResult && (
|
{step !== "basic" && !generationResult && (
|
||||||
<Button variant="outline" onClick={handleBack}>
|
<Button variant="outline" onClick={handleBack}>
|
||||||
이전
|
이전
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,9 @@ import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
Alert
|
|
||||||
Alert
|
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
Alert
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,9 @@ import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
Alert
|
|
||||||
Alert
|
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
Alert
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -243,7 +242,7 @@ const SaveDiagramModal: React.FC<SaveDiagramModalProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="flex gap-2">
|
<ResizableDialogFooter className="flex gap-2">
|
||||||
<Button variant="outline" onClick={handleClose} disabled={isLoading}>
|
<Button variant="outline" onClick={handleClose} disabled={isLoading}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -573,10 +573,10 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-4xl">
|
<DialogContent className="max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle>미리보기</ResizableDialogTitle>
|
<DialogTitle>미리보기</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
현재 레이아웃의 미리보기입니다. 인쇄하거나 파일로 다운로드할 수 있습니다.
|
현재 레이아웃의 미리보기입니다. 인쇄하거나 파일로 다운로드할 수 있습니다.
|
||||||
</ResizableDialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* 미리보기 영역 - 모든 페이지 표시 */}
|
{/* 미리보기 영역 - 모든 페이지 표시 */}
|
||||||
|
|
|
||||||
|
|
@ -72,10 +72,10 @@ export function SaveAsTemplateModal({ isOpen, onClose, onSave }: SaveAsTemplateM
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle>템플릿으로 저장</ResizableDialogTitle>
|
<DialogTitle>템플릿으로 저장</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
현재 리포트 레이아웃을 템플릿으로 저장하면 다른 리포트에서 재사용할 수 있습니다.
|
현재 리포트 레이아웃을 템플릿으로 저장하면 다른 리포트에서 재사용할 수 있습니다.
|
||||||
</ResizableDialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
|
|
|
||||||
|
|
@ -208,64 +208,23 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
// 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래
|
// 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래
|
||||||
// 너비 우선순위: table-list 100% > style.width > 조건부 100% > size.width (픽셀값)
|
// 🔥 모든 컴포넌트를 픽셀 기준으로 통일 (스케일로만 조정)
|
||||||
const getWidth = () => {
|
const getWidth = () => {
|
||||||
// 🔥 최우선: table-list는 항상 100% 사용 (화면 전체를 채움)
|
// table-list는 화면 너비 전체 사용
|
||||||
if (component.componentConfig?.type === "table-list") {
|
if (component.componentConfig?.type === "table-list") {
|
||||||
console.log("📏 [getWidth] 100% 사용 (table-list 최우선):", {
|
// 디자인 해상도 기준으로 픽셀 반환
|
||||||
|
const screenWidth = 1920; // 기본 디자인 해상도
|
||||||
|
console.log("📏 [getWidth] table-list 픽셀 사용:", {
|
||||||
componentId: id,
|
componentId: id,
|
||||||
label: component.label,
|
label: component.label,
|
||||||
positionX: position.x,
|
width: `${screenWidth}px`,
|
||||||
hasStyleWidth: !!componentStyle?.width,
|
|
||||||
styleWidth: componentStyle?.width,
|
|
||||||
});
|
});
|
||||||
return "100%";
|
return `${screenWidth}px`;
|
||||||
}
|
|
||||||
|
|
||||||
// 1순위: style.width가 있으면 우선 사용 (퍼센트 값)
|
|
||||||
if (componentStyle?.width) {
|
|
||||||
console.log("✅ [getWidth] style.width 사용:", {
|
|
||||||
componentId: id,
|
|
||||||
label: component.label,
|
|
||||||
styleWidth: componentStyle.width,
|
|
||||||
gridColumns: (component as any).gridColumns,
|
|
||||||
componentStyle: componentStyle,
|
|
||||||
baseStyle: {
|
|
||||||
left: `${position.x}px`,
|
|
||||||
top: `${position.y}px`,
|
|
||||||
width: componentStyle.width,
|
|
||||||
height: getHeight(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return componentStyle.width;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2순위: x=0인 컴포넌트는 전체 너비 사용 (버튼 제외)
|
|
||||||
const isButtonComponent =
|
|
||||||
(component.type === "widget" && (component as WidgetComponent).widgetType === "button") ||
|
|
||||||
(component.type === "component" && (component as any).componentType?.includes("button"));
|
|
||||||
|
|
||||||
if (position.x === 0 && !isButtonComponent) {
|
|
||||||
console.log("⚠️ [getWidth] 100% 사용 (x=0):", {
|
|
||||||
componentId: id,
|
|
||||||
label: component.label,
|
|
||||||
});
|
|
||||||
return "100%";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3순위: size.width (픽셀) - 버튼의 경우 항상 픽셀 사용
|
|
||||||
if (isButtonComponent && size?.width) {
|
|
||||||
const width = `${size.width}px`;
|
|
||||||
console.log("🔘 [getWidth] 버튼 픽셀 사용:", {
|
|
||||||
componentId: id,
|
|
||||||
label: component.label,
|
|
||||||
width,
|
|
||||||
});
|
|
||||||
return width;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 모든 컴포넌트는 size.width 픽셀 사용
|
||||||
const width = `${size?.width || 100}px`;
|
const width = `${size?.width || 100}px`;
|
||||||
console.log("📏 [getWidth] 픽셀 사용 (기본):", {
|
console.log("📐 [getWidth] 픽셀 기준 통일:", {
|
||||||
componentId: id,
|
componentId: id,
|
||||||
label: component.label,
|
label: component.label,
|
||||||
width,
|
width,
|
||||||
|
|
|
||||||
|
|
@ -1811,8 +1811,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const rect = canvasRef.current?.getBoundingClientRect();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
|
|
||||||
const dropX = e.clientX - rect.left;
|
// 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산
|
||||||
const dropY = e.clientY - rect.top;
|
const dropX = (e.clientX - rect.left) / zoomLevel;
|
||||||
|
const dropY = (e.clientY - rect.top) / zoomLevel;
|
||||||
|
|
||||||
// 현재 해상도에 맞는 격자 정보 계산
|
// 현재 해상도에 맞는 격자 정보 계산
|
||||||
const currentGridInfo = layout.gridSettings
|
const currentGridInfo = layout.gridSettings
|
||||||
|
|
@ -1830,9 +1831,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
|
? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
|
||||||
: { x: dropX, y: dropY, z: 1 };
|
: { x: dropX, y: dropY, z: 1 };
|
||||||
|
|
||||||
console.log("🏗️ 레이아웃 드롭:", {
|
console.log("🏗️ 레이아웃 드롭 (줌 보정):", {
|
||||||
|
zoomLevel,
|
||||||
layoutType: layoutData.layoutType,
|
layoutType: layoutData.layoutType,
|
||||||
zonesCount: layoutData.zones.length,
|
zonesCount: layoutData.zones.length,
|
||||||
|
mouseRaw: { x: e.clientX - rect.left, y: e.clientY - rect.top },
|
||||||
dropPosition: { x: dropX, y: dropY },
|
dropPosition: { x: dropX, y: dropY },
|
||||||
snappedPosition,
|
snappedPosition,
|
||||||
});
|
});
|
||||||
|
|
@ -1869,7 +1872,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
|
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
|
||||||
},
|
},
|
||||||
[layout, gridInfo, screenResolution, snapToGrid, saveToHistory],
|
[layout, gridInfo, screenResolution, snapToGrid, saveToHistory, zoomLevel],
|
||||||
);
|
);
|
||||||
|
|
||||||
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
|
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
|
||||||
|
|
@ -1954,32 +1957,47 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const componentWidth = component.defaultSize?.width || 120;
|
const componentWidth = component.defaultSize?.width || 120;
|
||||||
const componentHeight = component.defaultSize?.height || 36;
|
const componentHeight = component.defaultSize?.height || 36;
|
||||||
|
|
||||||
// 방법 1: 마우스 포인터를 컴포넌트 중심으로 (현재 방식)
|
// 🔥 중요: 줌 레벨과 transform-origin을 고려한 마우스 위치 계산
|
||||||
const dropX_centered = e.clientX - rect.left - componentWidth / 2;
|
// 1. 캔버스가 scale() 변환되어 있음 (transform-origin: top center)
|
||||||
const dropY_centered = e.clientY - rect.top - componentHeight / 2;
|
// 2. 캔버스가 justify-center로 중앙 정렬되어 있음
|
||||||
|
|
||||||
|
// 실제 캔버스 논리적 크기
|
||||||
|
const canvasLogicalWidth = screenResolution.width;
|
||||||
|
|
||||||
|
// 화면상 캔버스 실제 크기 (스케일 적용 후)
|
||||||
|
const canvasVisualWidth = canvasLogicalWidth * zoomLevel;
|
||||||
|
|
||||||
|
// 중앙 정렬로 인한 왼쪽 오프셋 계산
|
||||||
|
// rect.left는 이미 중앙 정렬된 위치를 반영하고 있음
|
||||||
|
|
||||||
|
// 마우스의 캔버스 내 상대 위치 (스케일 보정)
|
||||||
|
const mouseXInCanvas = (e.clientX - rect.left) / zoomLevel;
|
||||||
|
const mouseYInCanvas = (e.clientY - rect.top) / zoomLevel;
|
||||||
|
|
||||||
// 방법 2: 마우스 포인터를 컴포넌트 좌상단으로 (사용자가 원할 수도 있는 방식)
|
// 방법 1: 마우스 포인터를 컴포넌트 중심으로
|
||||||
const dropX_topleft = e.clientX - rect.left;
|
const dropX_centered = mouseXInCanvas - componentWidth / 2;
|
||||||
const dropY_topleft = e.clientY - rect.top;
|
const dropY_centered = mouseYInCanvas - componentHeight / 2;
|
||||||
|
|
||||||
|
// 방법 2: 마우스 포인터를 컴포넌트 좌상단으로
|
||||||
|
const dropX_topleft = mouseXInCanvas;
|
||||||
|
const dropY_topleft = mouseYInCanvas;
|
||||||
|
|
||||||
// 사용자가 원하는 방식으로 변경: 마우스 포인터가 좌상단에 오도록
|
// 사용자가 원하는 방식으로 변경: 마우스 포인터가 좌상단에 오도록
|
||||||
const dropX = dropX_topleft;
|
const dropX = dropX_topleft;
|
||||||
const dropY = dropY_topleft;
|
const dropY = dropY_topleft;
|
||||||
|
|
||||||
console.log("🎯 위치 계산 디버깅:", {
|
console.log("🎯 위치 계산 디버깅 (줌 레벨 + 중앙정렬 반영):", {
|
||||||
"1. 마우스 위치": { clientX: e.clientX, clientY: e.clientY },
|
"1. 줌 레벨": zoomLevel,
|
||||||
"2. 캔버스 위치": { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
|
"2. 마우스 위치 (화면)": { clientX: e.clientX, clientY: e.clientY },
|
||||||
"3. 캔버스 내 상대 위치": { x: e.clientX - rect.left, y: e.clientY - rect.top },
|
"3. 캔버스 위치 (rect)": { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
|
||||||
"4. 컴포넌트 크기": { width: componentWidth, height: componentHeight },
|
"4. 캔버스 논리적 크기": { width: canvasLogicalWidth, height: screenResolution.height },
|
||||||
"5a. 중심 방식 좌상단": { x: dropX_centered, y: dropY_centered },
|
"5. 캔버스 시각적 크기": { width: canvasVisualWidth, height: screenResolution.height * zoomLevel },
|
||||||
"5b. 좌상단 방식": { x: dropX_topleft, y: dropY_topleft },
|
"6. 마우스 캔버스 내 상대위치 (줌 전)": { x: e.clientX - rect.left, y: e.clientY - rect.top },
|
||||||
"6. 선택된 방식": { dropX, dropY },
|
"7. 마우스 캔버스 내 상대위치 (줌 보정)": { x: mouseXInCanvas, y: mouseYInCanvas },
|
||||||
"7. 예상 컴포넌트 중심": { x: dropX + componentWidth / 2, y: dropY + componentHeight / 2 },
|
"8. 컴포넌트 크기": { width: componentWidth, height: componentHeight },
|
||||||
"8. 마우스와 중심 일치 확인": {
|
"9a. 중심 방식": { x: dropX_centered, y: dropY_centered },
|
||||||
match:
|
"9b. 좌상단 방식": { x: dropX_topleft, y: dropY_topleft },
|
||||||
Math.abs(dropX + componentWidth / 2 - (e.clientX - rect.left)) < 1 &&
|
"10. 최종 선택": { dropX, dropY },
|
||||||
Math.abs(dropY + componentHeight / 2 - (e.clientY - rect.top)) < 1,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 현재 해상도에 맞는 격자 정보 계산
|
// 현재 해상도에 맞는 격자 정보 계산
|
||||||
|
|
@ -2826,7 +2844,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
// 컴포넌트 드래그 시작
|
// 컴포넌트 드래그 시작
|
||||||
const startComponentDrag = useCallback(
|
const startComponentDrag = useCallback(
|
||||||
(component: ComponentData, event: React.MouseEvent) => {
|
(component: ComponentData, event: React.MouseEvent | React.DragEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const rect = canvasRef.current?.getBoundingClientRect();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
|
|
@ -2839,9 +2857,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 캔버스 내부의 상대 좌표 계산 (스크롤 없는 고정 캔버스)
|
// 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산
|
||||||
const relativeMouseX = event.clientX - rect.left;
|
// 캔버스가 scale() 변환되어 있기 때문에 마우스 위치도 역변환 필요
|
||||||
const relativeMouseY = event.clientY - rect.top;
|
const relativeMouseX = (event.clientX - rect.left) / zoomLevel;
|
||||||
|
const relativeMouseY = (event.clientY - rect.top) / zoomLevel;
|
||||||
|
|
||||||
// 다중 선택된 컴포넌트들 확인
|
// 다중 선택된 컴포넌트들 확인
|
||||||
const isDraggedComponentSelected = groupState.selectedComponents.includes(component.id);
|
const isDraggedComponentSelected = groupState.selectedComponents.includes(component.id);
|
||||||
|
|
@ -2866,13 +2885,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log("드래그 시작:", component.id, "이동할 컴포넌트 수:", componentsToMove.length);
|
// console.log("드래그 시작:", component.id, "이동할 컴포넌트 수:", componentsToMove.length);
|
||||||
console.log("마우스 위치:", {
|
console.log("마우스 위치 (줌 보정):", {
|
||||||
|
zoomLevel,
|
||||||
clientX: event.clientX,
|
clientX: event.clientX,
|
||||||
clientY: event.clientY,
|
clientY: event.clientY,
|
||||||
rectLeft: rect.left,
|
rectLeft: rect.left,
|
||||||
rectTop: rect.top,
|
rectTop: rect.top,
|
||||||
relativeX: relativeMouseX,
|
mouseRaw: { x: event.clientX - rect.left, y: event.clientY - rect.top },
|
||||||
relativeY: relativeMouseY,
|
mouseZoomCorrected: { x: relativeMouseX, y: relativeMouseY },
|
||||||
componentX: component.position.x,
|
componentX: component.position.x,
|
||||||
componentY: component.position.y,
|
componentY: component.position.y,
|
||||||
grabOffsetX: relativeMouseX - component.position.x,
|
grabOffsetX: relativeMouseX - component.position.x,
|
||||||
|
|
@ -2906,7 +2926,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
justFinishedDrag: false,
|
justFinishedDrag: false,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[groupState.selectedComponents, layout.components, dragState.justFinishedDrag],
|
[groupState.selectedComponents, layout.components, dragState.justFinishedDrag, zoomLevel],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 드래그 중 위치 업데이트 (성능 최적화 + 실시간 업데이트)
|
// 드래그 중 위치 업데이트 (성능 최적화 + 실시간 업데이트)
|
||||||
|
|
@ -2916,9 +2936,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
const rect = canvasRef.current.getBoundingClientRect();
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
// 캔버스 내부의 상대 좌표 계산 (스크롤 없는 고정 캔버스)
|
// 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산
|
||||||
const relativeMouseX = event.clientX - rect.left;
|
// 캔버스가 scale() 변환되어 있기 때문에 마우스 위치도 역변환 필요
|
||||||
const relativeMouseY = event.clientY - rect.top;
|
const relativeMouseX = (event.clientX - rect.left) / zoomLevel;
|
||||||
|
const relativeMouseY = (event.clientY - rect.top) / zoomLevel;
|
||||||
|
|
||||||
// 컴포넌트 크기 가져오기
|
// 컴포넌트 크기 가져오기
|
||||||
const draggedComp = layout.components.find((c) => c.id === dragState.draggedComponent.id);
|
const draggedComp = layout.components.find((c) => c.id === dragState.draggedComponent.id);
|
||||||
|
|
@ -2936,8 +2957,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
};
|
};
|
||||||
|
|
||||||
// 드래그 상태 업데이트
|
// 드래그 상태 업데이트
|
||||||
console.log("🔥 ScreenDesigner updateDragPosition:", {
|
console.log("🔥 ScreenDesigner updateDragPosition (줌 보정):", {
|
||||||
|
zoomLevel,
|
||||||
draggedComponentId: dragState.draggedComponent.id,
|
draggedComponentId: dragState.draggedComponent.id,
|
||||||
|
mouseRaw: { x: event.clientX - rect.left, y: event.clientY - rect.top },
|
||||||
|
mouseZoomCorrected: { x: relativeMouseX, y: relativeMouseY },
|
||||||
oldPosition: dragState.currentPosition,
|
oldPosition: dragState.currentPosition,
|
||||||
newPosition: newPosition,
|
newPosition: newPosition,
|
||||||
});
|
});
|
||||||
|
|
@ -2961,7 +2985,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// 실제 레이아웃 업데이트는 endDrag에서 처리
|
// 실제 레이아웃 업데이트는 endDrag에서 처리
|
||||||
// 속성 패널에서는 dragState.currentPosition을 참조하여 실시간 표시
|
// 속성 패널에서는 dragState.currentPosition을 참조하여 실시간 표시
|
||||||
},
|
},
|
||||||
[dragState.isDragging, dragState.draggedComponent, dragState.grabOffset],
|
[dragState.isDragging, dragState.draggedComponent, dragState.grabOffset, zoomLevel],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 드래그 종료
|
// 드래그 종료
|
||||||
|
|
@ -4416,7 +4440,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */}
|
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬로 변경 */}
|
||||||
<div
|
<div
|
||||||
className="flex justify-center"
|
className="flex justify-center"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -4435,7 +4459,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
minHeight: `${screenResolution.height}px`,
|
minHeight: `${screenResolution.height}px`,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
transform: `scale(${zoomLevel})`,
|
transform: `scale(${zoomLevel})`,
|
||||||
transformOrigin: "top center",
|
transformOrigin: "top center", // 중앙 기준으로 스케일
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,13 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 최대 컬럼 수 계산 (최소 컬럼 너비 30px 기준)
|
||||||
|
const MIN_COLUMN_WIDTH = 30;
|
||||||
|
const maxColumns = screenResolution
|
||||||
|
? Math.floor((screenResolution.width - gridSettings.padding * 2 + gridSettings.gap) / (MIN_COLUMN_WIDTH + gridSettings.gap))
|
||||||
|
: 24;
|
||||||
|
const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한
|
||||||
|
|
||||||
// 실제 격자 정보 계산
|
// 실제 격자 정보 계산
|
||||||
const actualGridInfo = screenResolution
|
const actualGridInfo = screenResolution
|
||||||
? calculateGridInfo(screenResolution.width, screenResolution.height, {
|
? calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||||
|
|
@ -49,7 +56,7 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
// 컬럼이 너무 작은지 확인
|
// 컬럼이 너무 작은지 확인
|
||||||
const isColumnsTooSmall =
|
const isColumnsTooSmall =
|
||||||
screenResolution && actualGridInfo
|
screenResolution && actualGridInfo
|
||||||
? actualGridInfo.columnWidth < 30 // 30px 미만이면 너무 작다고 판단
|
? actualGridInfo.columnWidth < MIN_COLUMN_WIDTH
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -134,22 +141,22 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
id="columns"
|
id="columns"
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={24}
|
max={safeMaxColumns}
|
||||||
value={gridSettings.columns}
|
value={gridSettings.columns}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = parseInt(e.target.value, 10);
|
const value = parseInt(e.target.value, 10);
|
||||||
if (!isNaN(value) && value >= 1 && value <= 24) {
|
if (!isNaN(value) && value >= 1 && value <= safeMaxColumns) {
|
||||||
updateSetting("columns", value);
|
updateSetting("columns", value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs">/ 24</span>
|
<span className="text-muted-foreground text-xs">/ {safeMaxColumns}</span>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
id="columns-slider"
|
id="columns-slider"
|
||||||
min={1}
|
min={1}
|
||||||
max={24}
|
max={safeMaxColumns}
|
||||||
step={1}
|
step={1}
|
||||||
value={[gridSettings.columns]}
|
value={[gridSettings.columns]}
|
||||||
onValueChange={([value]) => updateSetting("columns", value)}
|
onValueChange={([value]) => updateSetting("columns", value)}
|
||||||
|
|
@ -157,8 +164,13 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
/>
|
/>
|
||||||
<div className="text-muted-foreground flex justify-between text-xs">
|
<div className="text-muted-foreground flex justify-between text-xs">
|
||||||
<span>1열</span>
|
<span>1열</span>
|
||||||
<span>24열</span>
|
<span>{safeMaxColumns}열</span>
|
||||||
</div>
|
</div>
|
||||||
|
{isColumnsTooSmall && (
|
||||||
|
<p className="text-xs text-amber-600">
|
||||||
|
⚠️ 컬럼 너비가 너무 작습니다 (최소 {MIN_COLUMN_WIDTH}px 권장)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,13 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
const renderGridSettings = () => {
|
const renderGridSettings = () => {
|
||||||
if (!gridSettings || !onGridSettingsChange) return null;
|
if (!gridSettings || !onGridSettingsChange) return null;
|
||||||
|
|
||||||
|
// 최대 컬럼 수 계산
|
||||||
|
const MIN_COLUMN_WIDTH = 30;
|
||||||
|
const maxColumns = currentResolution
|
||||||
|
? Math.floor((currentResolution.width - gridSettings.padding * 2 + gridSettings.gap) / (MIN_COLUMN_WIDTH + gridSettings.gap))
|
||||||
|
: 24;
|
||||||
|
const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|
@ -190,21 +197,22 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
id="columns"
|
id="columns"
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
|
max={safeMaxColumns}
|
||||||
step="1"
|
step="1"
|
||||||
value={gridSettings.columns}
|
value={gridSettings.columns}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = parseInt(e.target.value, 10);
|
const value = parseInt(e.target.value, 10);
|
||||||
if (!isNaN(value) && value >= 1) {
|
if (!isNaN(value) && value >= 1 && value <= safeMaxColumns) {
|
||||||
updateGridSetting("columns", value);
|
updateGridSetting("columns", value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="h-6 px-2 py-0 text-xs"
|
className="h-6 px-2 py-0 text-xs"
|
||||||
style={{ fontSize: "12px" }}
|
style={{ fontSize: "12px" }}
|
||||||
placeholder="1 이상의 숫자"
|
placeholder={`1~${safeMaxColumns}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-[10px]">
|
<p className="text-muted-foreground text-[10px]">
|
||||||
1 이상의 숫자를 입력하세요
|
최대 {safeMaxColumns}개까지 설정 가능 (최소 컬럼 너비 {MIN_COLUMN_WIDTH}px)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,17 +15,28 @@ export function calculateGridInfo(
|
||||||
containerHeight: number,
|
containerHeight: number,
|
||||||
gridSettings: GridSettings,
|
gridSettings: GridSettings,
|
||||||
): GridInfo {
|
): GridInfo {
|
||||||
const { columns, gap, padding } = gridSettings;
|
const { gap, padding } = gridSettings;
|
||||||
|
let { columns } = gridSettings;
|
||||||
|
|
||||||
// 사용 가능한 너비 계산 (패딩 제외)
|
// 🔥 최소 컬럼 너비를 보장하기 위한 최대 컬럼 수 계산
|
||||||
|
const MIN_COLUMN_WIDTH = 30; // 최소 컬럼 너비 30px
|
||||||
const availableWidth = containerWidth - padding * 2;
|
const availableWidth = containerWidth - padding * 2;
|
||||||
|
const maxPossibleColumns = Math.floor((availableWidth + gap) / (MIN_COLUMN_WIDTH + gap));
|
||||||
|
|
||||||
|
// 설정된 컬럼 수가 너무 많으면 자동으로 제한
|
||||||
|
if (columns > maxPossibleColumns) {
|
||||||
|
console.warn(
|
||||||
|
`⚠️ 격자 컬럼 수가 너무 많습니다. ${columns}개 → ${maxPossibleColumns}개로 자동 조정됨 (최소 컬럼 너비: ${MIN_COLUMN_WIDTH}px)`,
|
||||||
|
);
|
||||||
|
columns = Math.max(1, maxPossibleColumns);
|
||||||
|
}
|
||||||
|
|
||||||
// 격자 간격을 고려한 컬럼 너비 계산
|
// 격자 간격을 고려한 컬럼 너비 계산
|
||||||
const totalGaps = (columns - 1) * gap;
|
const totalGaps = (columns - 1) * gap;
|
||||||
const columnWidth = (availableWidth - totalGaps) / columns;
|
const columnWidth = (availableWidth - totalGaps) / columns;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
columnWidth: Math.max(columnWidth, 20), // 최소 20px로 줄여서 더 많은 컬럼 표시
|
columnWidth: Math.max(columnWidth, MIN_COLUMN_WIDTH),
|
||||||
totalWidth: containerWidth,
|
totalWidth: containerWidth,
|
||||||
totalHeight: containerHeight,
|
totalHeight: containerHeight,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue