Compare commits

..

No commits in common. "5b79bfb19d9811e3d038b848a9da684833a932ae" and "fb201cc799f60192d7329f41ea910c02499be4b8" have entirely different histories.

18 changed files with 178 additions and 194 deletions

View File

@ -63,9 +63,9 @@ export async function mergeCodeAllTables(
); );
// 결과 처리 (pool.query 반환 타입 처리) // 결과 처리 (pool.query 반환 타입 처리)
const affectedTables = Array.isArray(result) ? result : ((result as any).rows || []); const affectedTables = Array.isArray(result) ? result : (result.rows || []);
const totalRows = affectedTables.reduce( const totalRows = affectedTables.reduce(
(sum: number, row: any) => sum + parseInt(row.rows_updated || 0), (sum, row) => sum + parseInt(row.rows_updated || 0),
0 0
); );
@ -148,17 +148,16 @@ 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(`컬럼을 가진 테이블 조회 완료: ${rows.length}`); logger.info(`컬럼을 가진 테이블 조회 완료: ${result.rows.length}`);
res.json({ res.json({
success: true, success: true,
message: "테이블 목록 조회 성공", message: "테이블 목록 조회 성공",
data: { data: {
columnName, columnName,
tables: rows.map((row: any) => row.table_name), tables: result.rows.map((row) => row.table_name),
count: rows.length, count: result.rows.length,
}, },
}); });
} catch (error: any) { } catch (error: any) {
@ -224,7 +223,7 @@ export async function previewCodeMerge(
// 각 테이블에서 영향받을 행 수 계산 // 각 테이블에서 영향받을 행 수 계산
const preview = []; const preview = [];
const tableRows = Array.isArray(tablesResult) ? tablesResult : ((tablesResult as any).rows || []); const tableRows = Array.isArray(tablesResult) ? tablesResult : (tablesResult.rows || []);
for (const row of tableRows) { for (const row of tableRows) {
const tableName = row.table_name; const tableName = row.table_name;
@ -235,8 +234,7 @@ export async function previewCodeMerge(
try { try {
const countResult = await pool.query(countQuery, [oldValue, companyCode]); const countResult = await pool.query(countQuery, [oldValue, companyCode]);
const rows = (countResult as any).rows || []; const count = parseInt(countResult.rows[0].count);
const count = rows.length > 0 ? parseInt(rows[0].count) : 0;
if (count > 0) { if (count > 0) {
preview.push({ preview.push({

View File

@ -1,12 +1,11 @@
import { Response } from "express"; import { Request, 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: AuthenticatedRequest, res: Response) => { export const getCategoryColumns = async (req: Request, res: Response) => {
try { try {
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
const { tableName } = req.params; const { tableName } = req.params;
@ -33,7 +32,7 @@ export const getCategoryColumns = async (req: AuthenticatedRequest, res: Respons
/** /**
* ( ) * ( )
*/ */
export const getCategoryValues = async (req: AuthenticatedRequest, res: Response) => { export const getCategoryValues = async (req: Request, res: Response) => {
try { try {
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
const { tableName, columnName } = req.params; const { tableName, columnName } = req.params;
@ -63,7 +62,7 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response
/** /**
* *
*/ */
export const addCategoryValue = async (req: AuthenticatedRequest, res: Response) => { export const addCategoryValue = async (req: Request, res: Response) => {
try { try {
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
const userId = req.user!.userId; const userId = req.user!.userId;
@ -92,7 +91,7 @@ export const addCategoryValue = async (req: AuthenticatedRequest, res: Response)
/** /**
* *
*/ */
export const updateCategoryValue = async (req: AuthenticatedRequest, res: Response) => { export const updateCategoryValue = async (req: Request, res: Response) => {
try { try {
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
const userId = req.user!.userId; const userId = req.user!.userId;
@ -130,7 +129,7 @@ export const updateCategoryValue = async (req: AuthenticatedRequest, res: Respon
/** /**
* *
*/ */
export const deleteCategoryValue = async (req: AuthenticatedRequest, res: Response) => { export const deleteCategoryValue = async (req: Request, res: Response) => {
try { try {
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
const userId = req.user!.userId; const userId = req.user!.userId;
@ -167,7 +166,7 @@ export const deleteCategoryValue = async (req: AuthenticatedRequest, res: Respon
* *
*/ */
export const bulkDeleteCategoryValues = async ( export const bulkDeleteCategoryValues = async (
req: AuthenticatedRequest, req: Request,
res: Response res: Response
) => { ) => {
try { try {
@ -205,7 +204,7 @@ export const bulkDeleteCategoryValues = async (
/** /**
* *
*/ */
export const reorderCategoryValues = async (req: AuthenticatedRequest, res: Response) => { export const reorderCategoryValues = async (req: Request, res: Response) => {
try { try {
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
const { orderedValueIds } = req.body; const { orderedValueIds } = req.body;

View File

@ -1,13 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import { import * as tableCategoryValueController from "../controllers/tableCategoryValueController";
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();
@ -16,25 +8,43 @@ const router = Router();
router.use(authenticateToken); router.use(authenticateToken);
// 테이블의 카테고리 컬럼 목록 조회 // 테이블의 카테고리 컬럼 목록 조회
router.get("/:tableName/columns", getCategoryColumns); router.get(
"/:tableName/columns",
tableCategoryValueController.getCategoryColumns
);
// 카테고리 값 목록 조회 // 카테고리 값 목록 조회
router.get("/:tableName/:columnName/values", getCategoryValues); router.get(
"/:tableName/:columnName/values",
tableCategoryValueController.getCategoryValues
);
// 카테고리 값 추가 // 카테고리 값 추가
router.post("/values", addCategoryValue); router.post("/values", tableCategoryValueController.addCategoryValue);
// 카테고리 값 수정 // 카테고리 값 수정
router.put("/values/:valueId", updateCategoryValue); router.put(
"/values/:valueId",
tableCategoryValueController.updateCategoryValue
);
// 카테고리 값 삭제 // 카테고리 값 삭제
router.delete("/values/:valueId", deleteCategoryValue); router.delete(
"/values/:valueId",
tableCategoryValueController.deleteCategoryValue
);
// 카테고리 값 일괄 삭제 // 카테고리 값 일괄 삭제
router.post("/values/bulk-delete", bulkDeleteCategoryValues); router.post(
"/values/bulk-delete",
tableCategoryValueController.bulkDeleteCategoryValues
);
// 카테고리 값 순서 변경 // 카테고리 값 순서 변경
router.post("/values/reorder", reorderCategoryValues); router.post(
"/values/reorder",
tableCategoryValueController.reorderCategoryValues
);
export default router; export default router;

View File

@ -404,23 +404,19 @@ export class TableManagementService {
// 🔥 table_type_columns도 업데이트 (멀티테넌시 지원) // 🔥 table_type_columns도 업데이트 (멀티테넌시 지원)
if (settings.inputType) { if (settings.inputType) {
// detailSettings가 문자열이면 파싱, 객체면 그대로 사용 // detailSettings가 문자열이면 파싱, 객체면 그대로 사용
let parsedDetailSettings: Record<string, any> | undefined = undefined; let parsedDetailSettings = settings.detailSettings;
if (settings.detailSettings) {
if (typeof settings.detailSettings === 'string') { if (typeof settings.detailSettings === 'string') {
try { try {
parsedDetailSettings = JSON.parse(settings.detailSettings); parsedDetailSettings = JSON.parse(settings.detailSettings);
} catch (e) { } catch (e) {
logger.warn(`detailSettings 파싱 실패, 그대로 사용: ${settings.detailSettings}`); 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 as string, settings.inputType,
companyCode, companyCode,
parsedDetailSettings parsedDetailSettings
); );

View File

@ -67,6 +67,12 @@ export const INPUT_TYPE_OPTIONS: InputTypeOption[] = [
description: "단일 선택", description: "단일 선택",
category: "selection", category: "selection",
}, },
{
value: "image",
label: "이미지",
description: "이미지 표시",
category: "basic",
},
]; ];
// 입력 타입 검증 함수 // 입력 타입 검증 함수

View File

@ -225,21 +225,12 @@ 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 = Math.min(scaleX, scaleY); const newScale = scaleX;
console.log("📐 스케일 계산:", {
containerWidth,
containerHeight,
designWidth,
designHeight,
scaleX,
scaleY,
finalScale: newScale,
});
setScale(newScale); setScale(newScale);
// 컨테이너 너비 업데이트 // 컨테이너 너비 업데이트
@ -294,7 +285,7 @@ export default function ScreenViewPage() {
return ( return (
<ScreenPreviewProvider isPreviewMode={false}> <ScreenPreviewProvider isPreviewMode={false}>
<div ref={containerRef} className="bg-background flex h-full w-full items-center justify-center overflow-hidden"> <div ref={containerRef} className="bg-background h-full w-full 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">
@ -305,21 +296,17 @@ export default function ScreenViewPage() {
</div> </div>
)} )}
{/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */} {/* 절대 위치 기반 렌더링 */}
{layoutReady && layout && layout.components.length > 0 ? ( {layoutReady && layout && layout.components.length > 0 ? (
<div <div
className="bg-background relative" className="bg-background relative flex h-full origin-top-left items-start justify-start"
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",
}} }}
> >
{/* 최상위 컴포넌트들 렌더링 */} {/* 최상위 컴포넌트들 렌더링 */}

View File

@ -344,7 +344,7 @@ export default function BatchJobModal({
</Badge> </Badge>
</div> </div>
<ResizableDialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"

View File

@ -564,7 +564,7 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
)} )}
</div> </div>
<ResizableDialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
<Button <Button
variant="outline" variant="outline"
onClick={onClose} onClick={onClose}

View File

@ -499,7 +499,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
)} )}
</div> </div>
<ResizableDialogFooter className="gap-2"> <DialogFooter className="gap-2">
{step !== "basic" && !generationResult && ( {step !== "basic" && !generationResult && (
<Button variant="outline" onClick={handleBack}> <Button variant="outline" onClick={handleBack}>

View File

@ -13,9 +13,10 @@ import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
AlertDialogContent, AlertDialogContent,
Alert
Alert
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, Alert
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";

View File

@ -13,9 +13,10 @@ import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
AlertDialogContent, AlertDialogContent,
Alert
Alert
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, Alert
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";
@ -242,7 +243,7 @@ const SaveDiagramModal: React.FC<SaveDiagramModalProps> = ({
)} )}
</div> </div>
<ResizableDialogFooter className="flex gap-2"> <DialogFooter className="flex gap-2">
<Button variant="outline" onClick={handleClose} disabled={isLoading}> <Button variant="outline" onClick={handleClose} disabled={isLoading}>
</Button> </Button>

View File

@ -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>
<DialogTitle></DialogTitle> <ResizableDialogTitle></ResizableDialogTitle>
<DialogDescription> <DialogDescription>
. . . .
</DialogDescription> </ResizableDialogDescription>
</DialogHeader> </DialogHeader>
{/* 미리보기 영역 - 모든 페이지 표시 */} {/* 미리보기 영역 - 모든 페이지 표시 */}

View File

@ -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>
<DialogTitle>릿 </DialogTitle> <ResizableDialogTitle>릿 </ResizableDialogTitle>
<DialogDescription> <DialogDescription>
릿 . 릿 .
</DialogDescription> </ResizableDialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">

View File

@ -208,23 +208,64 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
: {}; : {};
// 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래 // 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래
// 🔥 모든 컴포넌트를 픽셀 기준으로 통일 (스케일로만 조정) // 너비 우선순위: table-list 100% > style.width > 조건부 100% > size.width (픽셀값)
const getWidth = () => { const getWidth = () => {
// table-list는 화면 너비 전체 사용 // 🔥 최우선: table-list는 항상 100% 사용 (화면 전체를 채움)
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,
width: `${screenWidth}px`, positionX: position.x,
hasStyleWidth: !!componentStyle?.width,
styleWidth: componentStyle?.width,
}); });
return `${screenWidth}px`; return "100%";
}
// 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,

View File

@ -1811,9 +1811,8 @@ 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 dropX = (e.clientX - rect.left) / zoomLevel; const dropY = e.clientY - rect.top;
const dropY = (e.clientY - rect.top) / zoomLevel;
// 현재 해상도에 맞는 격자 정보 계산 // 현재 해상도에 맞는 격자 정보 계산
const currentGridInfo = layout.gridSettings const currentGridInfo = layout.gridSettings
@ -1831,11 +1830,9 @@ 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,
}); });
@ -1872,7 +1869,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`); toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
}, },
[layout, gridInfo, screenResolution, snapToGrid, saveToHistory, zoomLevel], [layout, gridInfo, screenResolution, snapToGrid, saveToHistory],
); );
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨 // handleZoneComponentDrop은 handleComponentDrop으로 대체됨
@ -1957,47 +1954,32 @@ 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;
// 🔥 중요: 줌 레벨과 transform-origin을 고려한 마우스 위치 계산 // 방법 1: 마우스 포인터를 컴포넌트 중심으로 (현재 방식)
// 1. 캔버스가 scale() 변환되어 있음 (transform-origin: top center) const dropX_centered = e.clientX - rect.left - componentWidth / 2;
// 2. 캔버스가 justify-center로 중앙 정렬되어 있음 const dropY_centered = e.clientY - rect.top - componentHeight / 2;
// 실제 캔버스 논리적 크기 // 방법 2: 마우스 포인터를 컴포넌트 좌상단으로 (사용자가 원할 수도 있는 방식)
const canvasLogicalWidth = screenResolution.width; const dropX_topleft = e.clientX - rect.left;
const dropY_topleft = e.clientY - rect.top;
// 화면상 캔버스 실제 크기 (스케일 적용 후)
const canvasVisualWidth = canvasLogicalWidth * zoomLevel;
// 중앙 정렬로 인한 왼쪽 오프셋 계산
// rect.left는 이미 중앙 정렬된 위치를 반영하고 있음
// 마우스의 캔버스 내 상대 위치 (스케일 보정)
const mouseXInCanvas = (e.clientX - rect.left) / zoomLevel;
const mouseYInCanvas = (e.clientY - rect.top) / zoomLevel;
// 방법 1: 마우스 포인터를 컴포넌트 중심으로
const dropX_centered = mouseXInCanvas - componentWidth / 2;
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. 줌 레벨": zoomLevel, "1. 마우스 위치": { clientX: e.clientX, clientY: e.clientY },
"2. 마우스 위치 (화면)": { clientX: e.clientX, clientY: e.clientY }, "2. 캔버스 위치": { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
"3. 캔버스 위치 (rect)": { left: rect.left, top: rect.top, width: rect.width, height: rect.height }, "3. 캔버스 내 상대 위치": { x: e.clientX - rect.left, y: e.clientY - rect.top },
"4. 캔버스 논리적 크기": { width: canvasLogicalWidth, height: screenResolution.height }, "4. 컴포넌트 크기": { width: componentWidth, height: componentHeight },
"5. 캔버스 시각적 크기": { width: canvasVisualWidth, height: screenResolution.height * zoomLevel }, "5a. 중심 방식 좌상단": { x: dropX_centered, y: dropY_centered },
"6. 마우스 캔버스 내 상대위치 (줌 전)": { x: e.clientX - rect.left, y: e.clientY - rect.top }, "5b. 좌상단 방식": { x: dropX_topleft, y: dropY_topleft },
"7. 마우스 캔버스 내 상대위치 (줌 보정)": { x: mouseXInCanvas, y: mouseYInCanvas }, "6. 선택된 방식": { dropX, dropY },
"8. 컴포넌트 크기": { width: componentWidth, height: componentHeight }, "7. 예상 컴포넌트 중심": { x: dropX + componentWidth / 2, y: dropY + componentHeight / 2 },
"9a. 중심 방식": { x: dropX_centered, y: dropY_centered }, "8. 마우스와 중심 일치 확인": {
"9b. 좌상단 방식": { x: dropX_topleft, y: dropY_topleft }, match:
"10. 최종 선택": { dropX, dropY }, Math.abs(dropX + componentWidth / 2 - (e.clientX - rect.left)) < 1 &&
Math.abs(dropY + componentHeight / 2 - (e.clientY - rect.top)) < 1,
},
}); });
// 현재 해상도에 맞는 격자 정보 계산 // 현재 해상도에 맞는 격자 정보 계산
@ -2844,7 +2826,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 컴포넌트 드래그 시작 // 컴포넌트 드래그 시작
const startComponentDrag = useCallback( const startComponentDrag = useCallback(
(component: ComponentData, event: React.MouseEvent | React.DragEvent) => { (component: ComponentData, event: React.MouseEvent) => {
event.preventDefault(); event.preventDefault();
const rect = canvasRef.current?.getBoundingClientRect(); const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return; if (!rect) return;
@ -2857,10 +2839,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
})); }));
} }
// 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산 // 캔버스 내부의 상대 좌표 계산 (스크롤 없는 고정 캔버스)
// 캔버스가 scale() 변환되어 있기 때문에 마우스 위치도 역변환 필요 const relativeMouseX = event.clientX - rect.left;
const relativeMouseX = (event.clientX - rect.left) / zoomLevel; const relativeMouseY = event.clientY - rect.top;
const relativeMouseY = (event.clientY - rect.top) / zoomLevel;
// 다중 선택된 컴포넌트들 확인 // 다중 선택된 컴포넌트들 확인
const isDraggedComponentSelected = groupState.selectedComponents.includes(component.id); const isDraggedComponentSelected = groupState.selectedComponents.includes(component.id);
@ -2885,14 +2866,13 @@ 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,
mouseRaw: { x: event.clientX - rect.left, y: event.clientY - rect.top }, relativeX: relativeMouseX,
mouseZoomCorrected: { x: relativeMouseX, y: relativeMouseY }, relativeY: 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,
@ -2926,7 +2906,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
justFinishedDrag: false, justFinishedDrag: false,
}); });
}, },
[groupState.selectedComponents, layout.components, dragState.justFinishedDrag, zoomLevel], [groupState.selectedComponents, layout.components, dragState.justFinishedDrag],
); );
// 드래그 중 위치 업데이트 (성능 최적화 + 실시간 업데이트) // 드래그 중 위치 업데이트 (성능 최적화 + 실시간 업데이트)
@ -2936,10 +2916,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const rect = canvasRef.current.getBoundingClientRect(); const rect = canvasRef.current.getBoundingClientRect();
// 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산 // 캔버스 내부의 상대 좌표 계산 (스크롤 없는 고정 캔버스)
// 캔버스가 scale() 변환되어 있기 때문에 마우스 위치도 역변환 필요 const relativeMouseX = event.clientX - rect.left;
const relativeMouseX = (event.clientX - rect.left) / zoomLevel; const relativeMouseY = event.clientY - rect.top;
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);
@ -2957,11 +2936,8 @@ 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,
}); });
@ -2985,7 +2961,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 실제 레이아웃 업데이트는 endDrag에서 처리 // 실제 레이아웃 업데이트는 endDrag에서 처리
// 속성 패널에서는 dragState.currentPosition을 참조하여 실시간 표시 // 속성 패널에서는 dragState.currentPosition을 참조하여 실시간 표시
}, },
[dragState.isDragging, dragState.draggedComponent, dragState.grabOffset, zoomLevel], [dragState.isDragging, dragState.draggedComponent, dragState.grabOffset],
); );
// 드래그 종료 // 드래그 종료
@ -4440,7 +4416,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
</div> </div>
); );
})()} })()}
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬로 변경 */} {/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */}
<div <div
className="flex justify-center" className="flex justify-center"
style={{ style={{
@ -4459,7 +4435,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

View File

@ -33,13 +33,6 @@ 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, {
@ -56,7 +49,7 @@ export const GridPanel: React.FC<GridPanelProps> = ({
// 컬럼이 너무 작은지 확인 // 컬럼이 너무 작은지 확인
const isColumnsTooSmall = const isColumnsTooSmall =
screenResolution && actualGridInfo screenResolution && actualGridInfo
? actualGridInfo.columnWidth < MIN_COLUMN_WIDTH ? actualGridInfo.columnWidth < 30 // 30px 미만이면 너무 작다고 판단
: false; : false;
return ( return (
@ -141,22 +134,22 @@ export const GridPanel: React.FC<GridPanelProps> = ({
id="columns" id="columns"
type="number" type="number"
min={1} min={1}
max={safeMaxColumns} max={24}
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 <= safeMaxColumns) { if (!isNaN(value) && value >= 1 && value <= 24) {
updateSetting("columns", value); updateSetting("columns", value);
} }
}} }}
className="h-8 text-xs" className="h-8 text-xs"
/> />
<span className="text-muted-foreground text-xs">/ {safeMaxColumns}</span> <span className="text-muted-foreground text-xs">/ 24</span>
</div> </div>
<Slider <Slider
id="columns-slider" id="columns-slider"
min={1} min={1}
max={safeMaxColumns} max={24}
step={1} step={1}
value={[gridSettings.columns]} value={[gridSettings.columns]}
onValueChange={([value]) => updateSetting("columns", value)} onValueChange={([value]) => updateSetting("columns", value)}
@ -164,13 +157,8 @@ 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>{safeMaxColumns}</span> <span>24</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">

View File

@ -139,13 +139,6 @@ 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">
@ -197,22 +190,21 @@ 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 && value <= safeMaxColumns) { if (!isNaN(value) && value >= 1) {
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~${safeMaxColumns}`} placeholder="1 이상의 숫자"
/> />
</div> </div>
<p className="text-muted-foreground text-[10px]"> <p className="text-muted-foreground text-[10px]">
{safeMaxColumns} ( {MIN_COLUMN_WIDTH}px) 1
</p> </p>
</div> </div>

View File

@ -15,28 +15,17 @@ export function calculateGridInfo(
containerHeight: number, containerHeight: number,
gridSettings: GridSettings, gridSettings: GridSettings,
): GridInfo { ): GridInfo {
const { gap, padding } = gridSettings; const { columns, 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, MIN_COLUMN_WIDTH), columnWidth: Math.max(columnWidth, 20), // 최소 20px로 줄여서 더 많은 컬럼 표시
totalWidth: containerWidth, totalWidth: containerWidth,
totalHeight: containerHeight, totalHeight: containerHeight,
}; };