Merge branch 'main' into feature/screen-management
This commit is contained in:
@@ -12,12 +12,12 @@ const getApiBaseUrl = (): string => {
|
||||
const currentHost = window.location.hostname;
|
||||
const currentPort = window.location.port;
|
||||
|
||||
// 로컬 개발환경: localhost:9771 또는 localhost:3000 → localhost:8080
|
||||
// 🎯 로컬 개발환경: Next.js 프록시 사용 (대용량 요청 안정성)
|
||||
if (
|
||||
(currentHost === "localhost" || currentHost === "127.0.0.1") &&
|
||||
(currentPort === "9771" || currentPort === "3000")
|
||||
) {
|
||||
return "http://localhost:8080/api";
|
||||
return "/api"; // 프록시 사용
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,22 @@ const getApiBaseUrl = (): string => {
|
||||
|
||||
export const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
// 이미지 URL을 완전한 URL로 변환하는 함수
|
||||
export const getFullImageUrl = (imagePath: string): string => {
|
||||
// 이미 전체 URL인 경우 그대로 반환
|
||||
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
// /uploads로 시작하는 상대 경로인 경우 API 서버 주소 추가
|
||||
if (imagePath.startsWith("/uploads")) {
|
||||
const baseUrl = API_BASE_URL.replace("/api", ""); // /api 제거
|
||||
return `${baseUrl}${imagePath}`;
|
||||
}
|
||||
|
||||
return imagePath;
|
||||
};
|
||||
|
||||
// JWT 토큰 관리 유틸리티
|
||||
const TokenManager = {
|
||||
getToken: (): string | null => {
|
||||
|
||||
@@ -70,12 +70,76 @@ export interface UpdateMailTemplateDto extends Partial<CreateMailTemplateDto> {}
|
||||
export interface SendMailDto {
|
||||
accountId: string;
|
||||
templateId?: string;
|
||||
to: string[]; // 수신자 이메일 배열
|
||||
to: string[]; // 받는 사람
|
||||
cc?: string[]; // 참조 (Carbon Copy)
|
||||
bcc?: string[]; // 숨은참조 (Blind Carbon Copy)
|
||||
subject: string;
|
||||
variables?: Record<string, string>; // 템플릿 변수 치환
|
||||
customHtml?: string; // 템플릿 없이 직접 HTML 작성 시
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 발송 이력 타입
|
||||
// ============================================
|
||||
|
||||
export interface AttachmentInfo {
|
||||
filename: string;
|
||||
originalName: string;
|
||||
size: number;
|
||||
path: string;
|
||||
mimetype: string;
|
||||
}
|
||||
|
||||
export interface SentMailHistory {
|
||||
id: string;
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
accountEmail: string;
|
||||
to: string[];
|
||||
cc?: string[];
|
||||
bcc?: string[];
|
||||
subject: string;
|
||||
htmlContent: string;
|
||||
templateId?: string;
|
||||
templateName?: string;
|
||||
attachments?: AttachmentInfo[];
|
||||
sentAt: string;
|
||||
status: 'success' | 'failed';
|
||||
messageId?: string;
|
||||
errorMessage?: string;
|
||||
accepted?: string[];
|
||||
rejected?: string[];
|
||||
}
|
||||
|
||||
export interface SentMailListQuery {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
searchTerm?: string;
|
||||
status?: 'success' | 'failed' | 'all';
|
||||
accountId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
sortBy?: 'sentAt' | 'subject';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface SentMailListResponse {
|
||||
items: SentMailHistory[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface MailStatistics {
|
||||
totalSent: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
todayCount: number;
|
||||
thisMonthCount: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
export interface MailSendResult {
|
||||
success: boolean;
|
||||
messageId?: string;
|
||||
@@ -96,7 +160,7 @@ async function fetchApi<T>(
|
||||
|
||||
try {
|
||||
const response = await apiClient({
|
||||
url: `/mail${endpoint}`,
|
||||
url: endpoint, // `/mail` 접두사 제거 (apiClient는 이미 /api를 포함)
|
||||
method,
|
||||
data,
|
||||
});
|
||||
@@ -124,14 +188,14 @@ async function fetchApi<T>(
|
||||
* 전체 메일 계정 목록 조회
|
||||
*/
|
||||
export async function getMailAccounts(): Promise<MailAccount[]> {
|
||||
return fetchApi<MailAccount[]>('/accounts');
|
||||
return fetchApi<MailAccount[]>('/mail/accounts');
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 메일 계정 조회
|
||||
*/
|
||||
export async function getMailAccount(id: string): Promise<MailAccount> {
|
||||
return fetchApi<MailAccount>(`/accounts/${id}`);
|
||||
return fetchApi<MailAccount>(`/mail/accounts/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,7 +204,7 @@ export async function getMailAccount(id: string): Promise<MailAccount> {
|
||||
export async function createMailAccount(
|
||||
data: CreateMailAccountDto
|
||||
): Promise<MailAccount> {
|
||||
return fetchApi<MailAccount>('/accounts', {
|
||||
return fetchApi<MailAccount>('/mail/accounts', {
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
@@ -153,7 +217,7 @@ export async function updateMailAccount(
|
||||
id: string,
|
||||
data: UpdateMailAccountDto
|
||||
): Promise<MailAccount> {
|
||||
return fetchApi<MailAccount>(`/accounts/${id}`, {
|
||||
return fetchApi<MailAccount>(`/mail/accounts/${id}`, {
|
||||
method: 'PUT',
|
||||
data,
|
||||
});
|
||||
@@ -163,7 +227,7 @@ export async function updateMailAccount(
|
||||
* 메일 계정 삭제
|
||||
*/
|
||||
export async function deleteMailAccount(id: string): Promise<{ success: boolean }> {
|
||||
return fetchApi<{ success: boolean }>(`/accounts/${id}`, {
|
||||
return fetchApi<{ success: boolean }>(`/mail/accounts/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@@ -172,7 +236,7 @@ export async function deleteMailAccount(id: string): Promise<{ success: boolean
|
||||
* SMTP 연결 테스트
|
||||
*/
|
||||
export async function testMailAccountConnection(id: string): Promise<{ success: boolean; message: string }> {
|
||||
return fetchApi<{ success: boolean; message: string }>(`/accounts/${id}/test-connection`, {
|
||||
return fetchApi<{ success: boolean; message: string }>(`/mail/accounts/${id}/test-connection`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
@@ -185,7 +249,7 @@ export async function testMailConnection(id: string): Promise<{
|
||||
message: string;
|
||||
}> {
|
||||
return fetchApi<{ success: boolean; message: string }>(
|
||||
`/accounts/${id}/test-connection`,
|
||||
`/mail/accounts/${id}/test-connection`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
@@ -200,14 +264,14 @@ export async function testMailConnection(id: string): Promise<{
|
||||
* 전체 메일 템플릿 목록 조회
|
||||
*/
|
||||
export async function getMailTemplates(): Promise<MailTemplate[]> {
|
||||
return fetchApi<MailTemplate[]>('/templates-file');
|
||||
return fetchApi<MailTemplate[]>('/mail/templates-file');
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 메일 템플릿 조회
|
||||
*/
|
||||
export async function getMailTemplate(id: string): Promise<MailTemplate> {
|
||||
return fetchApi<MailTemplate>(`/templates-file/${id}`);
|
||||
return fetchApi<MailTemplate>(`/mail/templates-file/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,7 +280,7 @@ export async function getMailTemplate(id: string): Promise<MailTemplate> {
|
||||
export async function createMailTemplate(
|
||||
data: CreateMailTemplateDto
|
||||
): Promise<MailTemplate> {
|
||||
return fetchApi<MailTemplate>('/templates-file', {
|
||||
return fetchApi<MailTemplate>('/mail/templates-file', {
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
@@ -229,7 +293,7 @@ export async function updateMailTemplate(
|
||||
id: string,
|
||||
data: UpdateMailTemplateDto
|
||||
): Promise<MailTemplate> {
|
||||
return fetchApi<MailTemplate>(`/templates-file/${id}`, {
|
||||
return fetchApi<MailTemplate>(`/mail/templates-file/${id}`, {
|
||||
method: 'PUT',
|
||||
data,
|
||||
});
|
||||
@@ -239,7 +303,7 @@ export async function updateMailTemplate(
|
||||
* 메일 템플릿 삭제
|
||||
*/
|
||||
export async function deleteMailTemplate(id: string): Promise<{ success: boolean }> {
|
||||
return fetchApi<{ success: boolean }>(`/templates-file/${id}`, {
|
||||
return fetchApi<{ success: boolean }>(`/mail/templates-file/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@@ -251,7 +315,7 @@ export async function previewMailTemplate(
|
||||
id: string,
|
||||
sampleData?: Record<string, string>
|
||||
): Promise<{ html: string }> {
|
||||
return fetchApi<{ html: string }>(`/templates-file/${id}/preview`, {
|
||||
return fetchApi<{ html: string }>(`/mail/templates-file/${id}/preview`, {
|
||||
method: 'POST',
|
||||
data: { sampleData },
|
||||
});
|
||||
@@ -265,7 +329,7 @@ export async function previewMailTemplate(
|
||||
* 메일 발송 (단건 또는 소규모 발송)
|
||||
*/
|
||||
export async function sendMail(data: SendMailDto): Promise<MailSendResult> {
|
||||
return fetchApi<MailSendResult>('/send/simple', {
|
||||
return fetchApi<MailSendResult>('/mail/send/simple', {
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
@@ -407,6 +471,15 @@ export async function getReceivedMails(
|
||||
return fetchApi<ReceivedMail[]>(`/mail/receive/${accountId}?limit=${limit}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 오늘 수신한 메일 수 조회 (통계)
|
||||
*/
|
||||
export async function getTodayReceivedCount(accountId?: string): Promise<number> {
|
||||
const params = accountId ? `?accountId=${accountId}` : '';
|
||||
const response = await fetchApi<{ count: number }>(`/mail/receive/today-count${params}`);
|
||||
return response.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 상세 조회
|
||||
*/
|
||||
@@ -439,3 +512,52 @@ export async function testImapConnection(
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 발송 이력 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 발송 이력 목록 조회
|
||||
*/
|
||||
export async function getSentMailList(
|
||||
query: SentMailListQuery = {}
|
||||
): Promise<SentMailListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (query.page) params.append('page', query.page.toString());
|
||||
if (query.limit) params.append('limit', query.limit.toString());
|
||||
if (query.searchTerm) params.append('searchTerm', query.searchTerm);
|
||||
if (query.status && query.status !== 'all') params.append('status', query.status);
|
||||
if (query.accountId) params.append('accountId', query.accountId);
|
||||
if (query.startDate) params.append('startDate', query.startDate);
|
||||
if (query.endDate) params.append('endDate', query.endDate);
|
||||
if (query.sortBy) params.append('sortBy', query.sortBy);
|
||||
if (query.sortOrder) params.append('sortOrder', query.sortOrder);
|
||||
|
||||
return fetchApi(`/mail/sent?${params.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 발송 이력 상세 조회
|
||||
*/
|
||||
export async function getSentMailById(id: string): Promise<SentMailHistory> {
|
||||
return fetchApi(`/mail/sent/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 이력 삭제
|
||||
*/
|
||||
export async function deleteSentMail(id: string): Promise<{ success: boolean; message: string }> {
|
||||
return fetchApi(`/mail/sent/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 발송 통계 조회
|
||||
*/
|
||||
export async function getMailStatistics(accountId?: string): Promise<MailStatistics> {
|
||||
const params = accountId ? `?accountId=${accountId}` : '';
|
||||
return fetchApi(`/mail/sent/statistics${params}`);
|
||||
}
|
||||
|
||||
225
frontend/lib/api/reportApi.ts
Normal file
225
frontend/lib/api/reportApi.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { apiClient } from "./client";
|
||||
import {
|
||||
ReportMaster,
|
||||
ReportDetail,
|
||||
GetReportsParams,
|
||||
GetReportsResponse,
|
||||
CreateReportRequest,
|
||||
UpdateReportRequest,
|
||||
SaveLayoutRequest,
|
||||
GetTemplatesResponse,
|
||||
CreateTemplateRequest,
|
||||
ReportLayout,
|
||||
} from "@/types/report";
|
||||
|
||||
const BASE_URL = "/admin/reports";
|
||||
|
||||
export const reportApi = {
|
||||
// 리포트 목록 조회
|
||||
getReports: async (params: GetReportsParams) => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: GetReportsResponse;
|
||||
}>(BASE_URL, { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 리포트 상세 조회
|
||||
getReportById: async (reportId: string) => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: ReportDetail;
|
||||
}>(`${BASE_URL}/${reportId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 리포트 생성
|
||||
createReport: async (data: CreateReportRequest) => {
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
data: { reportId: string };
|
||||
message: string;
|
||||
}>(BASE_URL, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 리포트 수정
|
||||
updateReport: async (reportId: string, data: UpdateReportRequest) => {
|
||||
const response = await apiClient.put<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}>(`${BASE_URL}/${reportId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 리포트 삭제
|
||||
deleteReport: async (reportId: string) => {
|
||||
const response = await apiClient.delete<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}>(`${BASE_URL}/${reportId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 리포트 복사
|
||||
copyReport: async (reportId: string) => {
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
data: { reportId: string };
|
||||
message: string;
|
||||
}>(`${BASE_URL}/${reportId}/copy`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 레이아웃 조회
|
||||
getLayout: async (reportId: string) => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: ReportLayout;
|
||||
}>(`${BASE_URL}/${reportId}/layout`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 레이아웃 저장
|
||||
saveLayout: async (reportId: string, data: SaveLayoutRequest) => {
|
||||
const response = await apiClient.put<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}>(`${BASE_URL}/${reportId}/layout`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 템플릿 목록 조회
|
||||
getTemplates: async () => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: GetTemplatesResponse;
|
||||
}>(`${BASE_URL}/templates`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 템플릿 생성
|
||||
createTemplate: async (data: CreateTemplateRequest) => {
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
data: { templateId: string };
|
||||
message: string;
|
||||
}>(`${BASE_URL}/templates`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 템플릿 삭제
|
||||
deleteTemplate: async (templateId: string) => {
|
||||
const response = await apiClient.delete<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}>(`${BASE_URL}/templates/${templateId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 쿼리 실행
|
||||
executeQuery: async (
|
||||
reportId: string,
|
||||
queryId: string,
|
||||
parameters: Record<string, any>,
|
||||
sqlQuery?: string,
|
||||
externalConnectionId?: number | null,
|
||||
) => {
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
data: {
|
||||
fields: string[];
|
||||
rows: any[];
|
||||
};
|
||||
}>(`${BASE_URL}/${reportId}/queries/${queryId}/execute`, {
|
||||
parameters,
|
||||
sqlQuery,
|
||||
externalConnectionId,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 외부 DB 연결 목록 조회
|
||||
getExternalConnections: async () => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: any[];
|
||||
}>(`${BASE_URL}/external-connections`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 현재 리포트를 템플릿으로 저장
|
||||
saveAsTemplate: async (
|
||||
reportId: string,
|
||||
data: {
|
||||
templateNameKor: string;
|
||||
templateNameEng?: string;
|
||||
description?: string;
|
||||
},
|
||||
) => {
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
data: { templateId: string };
|
||||
message: string;
|
||||
}>(`${BASE_URL}/${reportId}/save-as-template`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
|
||||
createTemplateFromLayout: async (data: {
|
||||
templateNameKor: string;
|
||||
templateNameEng?: string;
|
||||
templateType?: string;
|
||||
description?: string;
|
||||
layoutConfig: {
|
||||
width: number;
|
||||
height: number;
|
||||
orientation: string;
|
||||
margins: {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
};
|
||||
components: any[];
|
||||
};
|
||||
defaultQueries?: Array<{
|
||||
name: string;
|
||||
type: "MASTER" | "DETAIL";
|
||||
sqlQuery: string;
|
||||
parameters: string[];
|
||||
externalConnectionId?: number | null;
|
||||
displayOrder?: number;
|
||||
}>;
|
||||
}) => {
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
data: { templateId: string };
|
||||
message: string;
|
||||
}>(`${BASE_URL}/templates/create-from-layout`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 이미지 업로드
|
||||
uploadImage: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
data: {
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
originalName: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
};
|
||||
message?: string;
|
||||
}>(`${BASE_URL}/upload-image`, formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -1,389 +1,155 @@
|
||||
import { Position, Size } from "@/types/screen";
|
||||
import { GridSettings } from "@/types/screen-management";
|
||||
import type { ComponentConfig, GridConfig } from "@/types/report";
|
||||
|
||||
export interface GridInfo {
|
||||
columnWidth: number;
|
||||
totalWidth: number;
|
||||
totalHeight: number;
|
||||
/**
|
||||
* 픽셀 좌표를 그리드 좌표로 변환
|
||||
*/
|
||||
export function pixelToGrid(pixel: number, cellSize: number): number {
|
||||
return Math.round(pixel / cellSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 격자 정보 계산
|
||||
* 그리드 좌표를 픽셀 좌표로 변환
|
||||
*/
|
||||
export function calculateGridInfo(
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
gridSettings: GridSettings,
|
||||
): GridInfo {
|
||||
const { columns, gap, padding } = gridSettings;
|
||||
export function gridToPixel(grid: number, cellSize: number): number {
|
||||
return grid * cellSize;
|
||||
}
|
||||
|
||||
// 사용 가능한 너비 계산 (패딩 제외)
|
||||
const availableWidth = containerWidth - padding * 2;
|
||||
/**
|
||||
* 컴포넌트 위치/크기를 그리드에 스냅
|
||||
*/
|
||||
export function snapComponentToGrid(component: ComponentConfig, gridConfig: GridConfig): ComponentConfig {
|
||||
if (!gridConfig.snapToGrid) {
|
||||
return component;
|
||||
}
|
||||
|
||||
// 격자 간격을 고려한 컬럼 너비 계산
|
||||
const totalGaps = (columns - 1) * gap;
|
||||
const columnWidth = (availableWidth - totalGaps) / columns;
|
||||
// 픽셀 좌표를 그리드 좌표로 변환
|
||||
const gridX = pixelToGrid(component.x, gridConfig.cellWidth);
|
||||
const gridY = pixelToGrid(component.y, gridConfig.cellHeight);
|
||||
const gridWidth = Math.max(1, pixelToGrid(component.width, gridConfig.cellWidth));
|
||||
const gridHeight = Math.max(1, pixelToGrid(component.height, gridConfig.cellHeight));
|
||||
|
||||
// 그리드 좌표를 다시 픽셀로 변환
|
||||
return {
|
||||
columnWidth: Math.max(columnWidth, 20), // 최소 20px로 줄여서 더 많은 컬럼 표시
|
||||
totalWidth: containerWidth,
|
||||
totalHeight: containerHeight,
|
||||
...component,
|
||||
gridX,
|
||||
gridY,
|
||||
gridWidth,
|
||||
gridHeight,
|
||||
x: gridToPixel(gridX, gridConfig.cellWidth),
|
||||
y: gridToPixel(gridY, gridConfig.cellHeight),
|
||||
width: gridToPixel(gridWidth, gridConfig.cellWidth),
|
||||
height: gridToPixel(gridHeight, gridConfig.cellHeight),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 위치를 격자에 맞춤
|
||||
* 그리드 충돌 감지
|
||||
* 두 컴포넌트가 겹치는지 확인
|
||||
*/
|
||||
export function snapToGrid(position: Position, gridInfo: GridInfo, gridSettings: GridSettings): Position {
|
||||
if (!gridSettings.snapToGrid) {
|
||||
return position;
|
||||
}
|
||||
|
||||
const { columnWidth } = gridInfo;
|
||||
const { gap, padding } = gridSettings;
|
||||
|
||||
// 격자 셀 크기 (컬럼 너비 + 간격을 하나의 격자 단위로 계산)
|
||||
const cellWidth = columnWidth + gap;
|
||||
const cellHeight = Math.max(40, gap * 2); // 행 높이를 더 크게 설정
|
||||
|
||||
// 패딩을 제외한 상대 위치
|
||||
const relativeX = position.x - padding;
|
||||
const relativeY = position.y - padding;
|
||||
|
||||
// 격자 기준으로 위치 계산 (가장 가까운 격자점으로 스냅)
|
||||
const gridX = Math.round(relativeX / cellWidth);
|
||||
const gridY = Math.round(relativeY / cellHeight);
|
||||
|
||||
// 실제 픽셀 위치로 변환
|
||||
const snappedX = Math.max(padding, padding + gridX * cellWidth);
|
||||
const snappedY = Math.max(padding, padding + gridY * cellHeight);
|
||||
|
||||
return {
|
||||
x: snappedX,
|
||||
y: snappedY,
|
||||
z: position.z,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 크기를 격자에 맞춤
|
||||
*/
|
||||
export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: GridSettings): Size {
|
||||
if (!gridSettings.snapToGrid) {
|
||||
return size;
|
||||
}
|
||||
|
||||
const { columnWidth } = gridInfo;
|
||||
const { gap } = gridSettings;
|
||||
|
||||
// 격자 단위로 너비 계산
|
||||
// 컴포넌트가 차지하는 컬럼 수를 올바르게 계산
|
||||
let gridColumns = 1;
|
||||
|
||||
// 현재 너비에서 가장 가까운 격자 컬럼 수 찾기
|
||||
for (let cols = 1; cols <= gridSettings.columns; cols++) {
|
||||
const targetWidth = cols * columnWidth + (cols - 1) * gap;
|
||||
if (size.width <= targetWidth + (columnWidth + gap) / 2) {
|
||||
gridColumns = cols;
|
||||
break;
|
||||
}
|
||||
gridColumns = cols;
|
||||
}
|
||||
|
||||
const snappedWidth = gridColumns * columnWidth + (gridColumns - 1) * gap;
|
||||
|
||||
// 높이는 동적 행 높이 단위로 스냅
|
||||
const rowHeight = Math.max(20, gap);
|
||||
const snappedHeight = Math.max(40, Math.round(size.height / rowHeight) * rowHeight);
|
||||
|
||||
console.log(
|
||||
`📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
|
||||
);
|
||||
|
||||
return {
|
||||
width: Math.max(columnWidth, snappedWidth),
|
||||
height: snappedHeight,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 격자 컬럼 수로 너비 계산
|
||||
*/
|
||||
export function calculateWidthFromColumns(columns: number, gridInfo: GridInfo, gridSettings: GridSettings): number {
|
||||
const { columnWidth } = gridInfo;
|
||||
const { gap } = gridSettings;
|
||||
|
||||
return columns * columnWidth + (columns - 1) * gap;
|
||||
}
|
||||
|
||||
/**
|
||||
* gridColumns 속성을 기반으로 컴포넌트 크기 업데이트
|
||||
*/
|
||||
export function updateSizeFromGridColumns(
|
||||
component: { gridColumns?: number; size: Size },
|
||||
gridInfo: GridInfo,
|
||||
gridSettings: GridSettings,
|
||||
): Size {
|
||||
if (!component.gridColumns || component.gridColumns < 1) {
|
||||
return component.size;
|
||||
}
|
||||
|
||||
const newWidth = calculateWidthFromColumns(component.gridColumns, gridInfo, gridSettings);
|
||||
|
||||
return {
|
||||
width: newWidth,
|
||||
height: component.size.height, // 높이는 유지
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트의 gridColumns를 자동으로 크기에 맞게 조정
|
||||
*/
|
||||
export function adjustGridColumnsFromSize(
|
||||
component: { size: Size },
|
||||
gridInfo: GridInfo,
|
||||
gridSettings: GridSettings,
|
||||
): number {
|
||||
const columns = calculateColumnsFromWidth(component.size.width, gridInfo, gridSettings);
|
||||
return Math.min(Math.max(1, columns), gridSettings.columns); // 1-12 범위로 제한
|
||||
}
|
||||
|
||||
/**
|
||||
* 너비에서 격자 컬럼 수 계산
|
||||
*/
|
||||
export function calculateColumnsFromWidth(width: number, gridInfo: GridInfo, gridSettings: GridSettings): number {
|
||||
const { columnWidth } = gridInfo;
|
||||
const { gap } = gridSettings;
|
||||
|
||||
return Math.max(1, Math.round((width + gap) / (columnWidth + gap)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 격자 가이드라인 생성
|
||||
*/
|
||||
export function generateGridLines(
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
gridSettings: GridSettings,
|
||||
): {
|
||||
verticalLines: number[];
|
||||
horizontalLines: number[];
|
||||
} {
|
||||
const { columns, gap, padding } = gridSettings;
|
||||
const gridInfo = calculateGridInfo(containerWidth, containerHeight, gridSettings);
|
||||
const { columnWidth } = gridInfo;
|
||||
|
||||
// 격자 셀 크기 (스냅 로직과 동일하게)
|
||||
const cellWidth = columnWidth + gap;
|
||||
const cellHeight = Math.max(40, gap * 2);
|
||||
|
||||
// 세로 격자선
|
||||
const verticalLines: number[] = [];
|
||||
for (let i = 0; i <= columns; i++) {
|
||||
const x = padding + i * cellWidth;
|
||||
if (x <= containerWidth) {
|
||||
verticalLines.push(x);
|
||||
}
|
||||
}
|
||||
|
||||
// 가로 격자선
|
||||
const horizontalLines: number[] = [];
|
||||
for (let y = padding; y < containerHeight; y += cellHeight) {
|
||||
horizontalLines.push(y);
|
||||
}
|
||||
|
||||
return {
|
||||
verticalLines,
|
||||
horizontalLines,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트가 격자 경계에 있는지 확인
|
||||
*/
|
||||
export function isOnGridBoundary(
|
||||
position: Position,
|
||||
size: Size,
|
||||
gridInfo: GridInfo,
|
||||
gridSettings: GridSettings,
|
||||
tolerance: number = 5,
|
||||
export function detectGridCollision(
|
||||
component: ComponentConfig,
|
||||
otherComponents: ComponentConfig[],
|
||||
gridConfig: GridConfig,
|
||||
): boolean {
|
||||
const snappedPos = snapToGrid(position, gridInfo, gridSettings);
|
||||
const snappedSize = snapSizeToGrid(size, gridInfo, gridSettings);
|
||||
const comp1GridX = component.gridX ?? pixelToGrid(component.x, gridConfig.cellWidth);
|
||||
const comp1GridY = component.gridY ?? pixelToGrid(component.y, gridConfig.cellHeight);
|
||||
const comp1GridWidth = component.gridWidth ?? pixelToGrid(component.width, gridConfig.cellWidth);
|
||||
const comp1GridHeight = component.gridHeight ?? pixelToGrid(component.height, gridConfig.cellHeight);
|
||||
|
||||
const positionMatch =
|
||||
Math.abs(position.x - snappedPos.x) <= tolerance && Math.abs(position.y - snappedPos.y) <= tolerance;
|
||||
for (const other of otherComponents) {
|
||||
if (other.id === component.id) continue;
|
||||
|
||||
const sizeMatch =
|
||||
Math.abs(size.width - snappedSize.width) <= tolerance && Math.abs(size.height - snappedSize.height) <= tolerance;
|
||||
const comp2GridX = other.gridX ?? pixelToGrid(other.x, gridConfig.cellWidth);
|
||||
const comp2GridY = other.gridY ?? pixelToGrid(other.y, gridConfig.cellHeight);
|
||||
const comp2GridWidth = other.gridWidth ?? pixelToGrid(other.width, gridConfig.cellWidth);
|
||||
const comp2GridHeight = other.gridHeight ?? pixelToGrid(other.height, gridConfig.cellHeight);
|
||||
|
||||
return positionMatch && sizeMatch;
|
||||
}
|
||||
// AABB (Axis-Aligned Bounding Box) 충돌 감지
|
||||
const xOverlap = comp1GridX < comp2GridX + comp2GridWidth && comp1GridX + comp1GridWidth > comp2GridX;
|
||||
const yOverlap = comp1GridY < comp2GridY + comp2GridHeight && comp1GridY + comp1GridHeight > comp2GridY;
|
||||
|
||||
/**
|
||||
* 그룹 내부 컴포넌트들을 격자에 맞게 정렬
|
||||
*/
|
||||
export function alignGroupChildrenToGrid(
|
||||
children: any[],
|
||||
groupPosition: Position,
|
||||
gridInfo: GridInfo,
|
||||
gridSettings: GridSettings,
|
||||
): any[] {
|
||||
if (!gridSettings.snapToGrid || children.length === 0) return children;
|
||||
|
||||
console.log("🔧 alignGroupChildrenToGrid 시작:", {
|
||||
childrenCount: children.length,
|
||||
groupPosition,
|
||||
gridInfo,
|
||||
gridSettings,
|
||||
});
|
||||
|
||||
return children.map((child, index) => {
|
||||
console.log(`📐 자식 ${index + 1} 처리 중:`, {
|
||||
childId: child.id,
|
||||
originalPosition: child.position,
|
||||
originalSize: child.size,
|
||||
});
|
||||
|
||||
const { columnWidth } = gridInfo;
|
||||
const { gap } = gridSettings;
|
||||
|
||||
// 그룹 내부 패딩 고려한 격자 정렬
|
||||
const padding = 16;
|
||||
const effectiveX = child.position.x - padding;
|
||||
const columnIndex = Math.round(effectiveX / (columnWidth + gap));
|
||||
const snappedX = padding + columnIndex * (columnWidth + gap);
|
||||
|
||||
// Y 좌표는 동적 행 높이 단위로 스냅
|
||||
const rowHeight = Math.max(20, gap);
|
||||
const effectiveY = child.position.y - padding;
|
||||
const rowIndex = Math.round(effectiveY / rowHeight);
|
||||
const snappedY = padding + rowIndex * rowHeight;
|
||||
|
||||
// 크기는 외부 격자와 동일하게 스냅 (columnWidth + gap 사용)
|
||||
const fullColumnWidth = columnWidth + gap; // 외부 격자와 동일한 크기
|
||||
const widthInColumns = Math.max(1, Math.round(child.size.width / fullColumnWidth));
|
||||
const snappedWidth = widthInColumns * fullColumnWidth - gap; // gap 제거하여 실제 컴포넌트 크기
|
||||
const snappedHeight = Math.max(40, Math.round(child.size.height / rowHeight) * rowHeight);
|
||||
|
||||
const snappedChild = {
|
||||
...child,
|
||||
position: {
|
||||
x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
|
||||
y: Math.max(padding, snappedY),
|
||||
z: child.position.z || 1,
|
||||
},
|
||||
size: {
|
||||
width: snappedWidth,
|
||||
height: snappedHeight,
|
||||
},
|
||||
};
|
||||
|
||||
console.log(`✅ 자식 ${index + 1} 격자 정렬 완료:`, {
|
||||
childId: child.id,
|
||||
calculation: {
|
||||
effectiveX,
|
||||
effectiveY,
|
||||
columnIndex,
|
||||
rowIndex,
|
||||
widthInColumns,
|
||||
originalX: child.position.x,
|
||||
snappedX: snappedChild.position.x,
|
||||
padding,
|
||||
},
|
||||
snappedPosition: snappedChild.position,
|
||||
snappedSize: snappedChild.size,
|
||||
deltaX: snappedChild.position.x - child.position.x,
|
||||
deltaY: snappedChild.position.y - child.position.y,
|
||||
});
|
||||
|
||||
return snappedChild;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹 생성 시 최적화된 그룹 크기 계산
|
||||
*/
|
||||
export function calculateOptimalGroupSize(
|
||||
children: Array<{ position: Position; size: Size }>,
|
||||
gridInfo: GridInfo,
|
||||
gridSettings: GridSettings,
|
||||
): Size {
|
||||
if (children.length === 0) {
|
||||
return { width: gridInfo.columnWidth * 2, height: 40 * 2 };
|
||||
if (xOverlap && yOverlap) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("📏 calculateOptimalGroupSize 시작:", {
|
||||
childrenCount: children.length,
|
||||
children: children.map((c) => ({ pos: c.position, size: c.size })),
|
||||
});
|
||||
|
||||
// 모든 자식 컴포넌트를 포함하는 최소 경계 계산
|
||||
const bounds = children.reduce(
|
||||
(acc, child) => ({
|
||||
minX: Math.min(acc.minX, child.position.x),
|
||||
minY: Math.min(acc.minY, child.position.y),
|
||||
maxX: Math.max(acc.maxX, child.position.x + child.size.width),
|
||||
maxY: Math.max(acc.maxY, child.position.y + child.size.height),
|
||||
}),
|
||||
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
||||
);
|
||||
|
||||
console.log("📐 경계 계산:", bounds);
|
||||
|
||||
const contentWidth = bounds.maxX - bounds.minX;
|
||||
const contentHeight = bounds.maxY - bounds.minY;
|
||||
|
||||
// 그룹은 격자 스냅 없이 컨텐츠에 맞는 자연스러운 크기
|
||||
const padding = 16; // 그룹 내부 여백
|
||||
const groupSize = {
|
||||
width: contentWidth + padding * 2,
|
||||
height: contentHeight + padding * 2,
|
||||
};
|
||||
|
||||
console.log("✅ 자연스러운 그룹 크기:", {
|
||||
contentSize: { width: contentWidth, height: contentHeight },
|
||||
withPadding: groupSize,
|
||||
strategy: "그룹은 격자 스냅 없이, 내부 컴포넌트만 격자에 맞춤",
|
||||
});
|
||||
|
||||
return groupSize;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹 내 상대 좌표를 격자 기준으로 정규화
|
||||
* 페이지 크기 기반 그리드 행/열 계산
|
||||
*/
|
||||
export function normalizeGroupChildPositions(children: any[], gridSettings: GridSettings): any[] {
|
||||
if (!gridSettings.snapToGrid || children.length === 0) return children;
|
||||
|
||||
console.log("🔄 normalizeGroupChildPositions 시작:", {
|
||||
childrenCount: children.length,
|
||||
originalPositions: children.map((c) => ({ id: c.id, pos: c.position })),
|
||||
});
|
||||
|
||||
// 모든 자식의 최소 위치 찾기
|
||||
const minX = Math.min(...children.map((child) => child.position.x));
|
||||
const minY = Math.min(...children.map((child) => child.position.y));
|
||||
|
||||
console.log("📍 최소 위치:", { minX, minY });
|
||||
|
||||
// 그룹 내에서 시작점을 패딩만큼 떨어뜨림 (자연스러운 여백)
|
||||
const padding = 16;
|
||||
const startX = padding;
|
||||
const startY = padding;
|
||||
|
||||
const normalizedChildren = children.map((child) => ({
|
||||
...child,
|
||||
position: {
|
||||
x: child.position.x - minX + startX,
|
||||
y: child.position.y - minY + startY,
|
||||
z: child.position.z || 1,
|
||||
},
|
||||
}));
|
||||
|
||||
console.log("✅ 정규화 완료:", {
|
||||
normalizedPositions: normalizedChildren.map((c) => ({ id: c.id, pos: c.position })),
|
||||
});
|
||||
|
||||
return normalizedChildren;
|
||||
export function calculateGridDimensions(
|
||||
pageWidth: number,
|
||||
pageHeight: number,
|
||||
cellWidth: number,
|
||||
cellHeight: number,
|
||||
): { rows: number; columns: number } {
|
||||
return {
|
||||
columns: Math.floor(pageWidth / cellWidth),
|
||||
rows: Math.floor(pageHeight / cellHeight),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 그리드 설정 생성
|
||||
*/
|
||||
export function createDefaultGridConfig(pageWidth: number, pageHeight: number): GridConfig {
|
||||
const cellWidth = 20;
|
||||
const cellHeight = 20;
|
||||
const { rows, columns } = calculateGridDimensions(pageWidth, pageHeight, cellWidth, cellHeight);
|
||||
|
||||
return {
|
||||
cellWidth,
|
||||
cellHeight,
|
||||
rows,
|
||||
columns,
|
||||
visible: true,
|
||||
snapToGrid: true,
|
||||
gridColor: "#e5e7eb",
|
||||
gridOpacity: 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 위치가 페이지 경계 내에 있는지 확인
|
||||
*/
|
||||
export function isWithinPageBounds(
|
||||
component: ComponentConfig,
|
||||
pageWidth: number,
|
||||
pageHeight: number,
|
||||
margins: { top: number; bottom: number; left: number; right: number },
|
||||
): boolean {
|
||||
const minX = margins.left;
|
||||
const minY = margins.top;
|
||||
const maxX = pageWidth - margins.right;
|
||||
const maxY = pageHeight - margins.bottom;
|
||||
|
||||
return (
|
||||
component.x >= minX &&
|
||||
component.y >= minY &&
|
||||
component.x + component.width <= maxX &&
|
||||
component.y + component.height <= maxY
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트를 페이지 경계 내로 제한
|
||||
*/
|
||||
export function constrainToPageBounds(
|
||||
component: ComponentConfig,
|
||||
pageWidth: number,
|
||||
pageHeight: number,
|
||||
margins: { top: number; bottom: number; left: number; right: number },
|
||||
): ComponentConfig {
|
||||
const minX = margins.left;
|
||||
const minY = margins.top;
|
||||
const maxX = pageWidth - margins.right - component.width;
|
||||
const maxY = pageHeight - margins.bottom - component.height;
|
||||
|
||||
return {
|
||||
...component,
|
||||
x: Math.max(minX, Math.min(maxX, component.x)),
|
||||
y: Math.max(minY, Math.min(maxY, component.y)),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user