Compare commits

..

16 Commits

Author SHA1 Message Date
leeheejin d819a7c77e Merge branch 'main' into lhj - 위젯 UI 개선사항 유지 2025-10-16 13:50:23 +09:00
hyeonsu 26d27ac881 Merge pull request '전체적인 레이아웃 변경 및 해상도 셀렉트' (#103) from feature/dashboard into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/103
2025-10-16 13:48:37 +09:00
dohyeons c5499d2e18 해상도 변경 시 요소들 간격 조절 구현 2025-10-16 11:55:14 +09:00
dohyeons a8c1b4b5e5 리스트 뷰/편집 모드에서 동일한 레이아웃 제공 2025-10-16 11:49:06 +09:00
dohyeons 8e0ef82de7 해상도 복원 구현 2025-10-16 11:29:45 +09:00
dohyeons 3bda194bf2 캔버스 동적 높이 기능 구현 2025-10-16 11:09:11 +09:00
dohyeons a7123216f2 백엔드 수정 2025-10-16 11:03:57 +09:00
dohyeons 9168844fab Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/dashboard 2025-10-16 10:56:34 +09:00
dohyeons 02f67c2372 진짜 마지막 수정임 2025-10-16 10:47:24 +09:00
dohyeons 9349867476 배포 스크립트 변경 2025-10-16 10:39:48 +09:00
dohyeons 8f676c0a6d 배포용 수정 2025-10-16 10:33:21 +09:00
dohyeons 9e1a7c53e1 settings 저장 2025-10-16 10:27:43 +09:00
dohyeons 337cc448d0 리사이즈 핸들러 오류 수정 2025-10-16 10:09:10 +09:00
dohyeons ed9da3962a 에러 수정 2025-10-16 10:05:43 +09:00
dohyeons 3afcd3d9fb 같은 요소를 연속으로 꺼낼 수 있게 수정 2025-10-16 10:02:47 +09:00
dohyeons 18e2280623 대시보드 방식 이전 2025-10-16 09:55:14 +09:00
20 changed files with 1019 additions and 393 deletions

View File

@ -1,91 +1,98 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from "uuid";
import { PostgreSQLService } from '../database/PostgreSQLService'; import { PostgreSQLService } from "../database/PostgreSQLService";
import { import {
Dashboard, Dashboard,
DashboardElement, DashboardElement,
CreateDashboardRequest, CreateDashboardRequest,
UpdateDashboardRequest, UpdateDashboardRequest,
DashboardListQuery DashboardListQuery,
} from '../types/dashboard'; } from "../types/dashboard";
/** /**
* - Raw Query * - Raw Query
* PostgreSQL CRUD * PostgreSQL CRUD
*/ */
export class DashboardService { export class DashboardService {
/** /**
* *
*/ */
static async createDashboard(data: CreateDashboardRequest, userId: string): Promise<Dashboard> { static async createDashboard(
data: CreateDashboardRequest,
userId: string
): Promise<Dashboard> {
const dashboardId = uuidv4(); const dashboardId = uuidv4();
const now = new Date(); const now = new Date();
try { try {
// 트랜잭션으로 대시보드와 요소들을 함께 생성 // 트랜잭션으로 대시보드와 요소들을 함께 생성
const result = await PostgreSQLService.transaction(async (client) => { const result = await PostgreSQLService.transaction(async (client) => {
// 1. 대시보드 메인 정보 저장 // 1. 대시보드 메인 정보 저장
await client.query(` await client.query(
`
INSERT INTO dashboards ( INSERT INTO dashboards (
id, title, description, is_public, created_by, id, title, description, is_public, created_by,
created_at, updated_at, tags, category, view_count created_at, updated_at, tags, category, view_count, settings
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`, [ `,
dashboardId, [
data.title, dashboardId,
data.description || null, data.title,
data.isPublic || false, data.description || null,
userId, data.isPublic || false,
now, userId,
now, now,
JSON.stringify(data.tags || []), now,
data.category || null, JSON.stringify(data.tags || []),
0 data.category || null,
]); 0,
JSON.stringify(data.settings || {}),
]
);
// 2. 대시보드 요소들 저장 // 2. 대시보드 요소들 저장
if (data.elements && data.elements.length > 0) { if (data.elements && data.elements.length > 0) {
for (let i = 0; i < data.elements.length; i++) { for (let i = 0; i < data.elements.length; i++) {
const element = data.elements[i]; const element = data.elements[i];
const elementId = uuidv4(); // 항상 새로운 UUID 생성 const elementId = uuidv4(); // 항상 새로운 UUID 생성
await client.query(` await client.query(
`
INSERT INTO dashboard_elements ( INSERT INTO dashboard_elements (
id, dashboard_id, element_type, element_subtype, id, dashboard_id, element_type, element_subtype,
position_x, position_y, width, height, position_x, position_y, width, height,
title, custom_title, show_header, content, data_source_config, chart_config, title, content, data_source_config, chart_config,
display_order, created_at, updated_at display_order, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
`, [ `,
elementId, [
dashboardId, elementId,
element.type, dashboardId,
element.subtype, element.type,
element.position.x, element.subtype,
element.position.y, element.position.x,
element.size.width, element.position.y,
element.size.height, element.size.width,
element.title, element.size.height,
element.customTitle || null, element.title,
element.showHeader !== false, element.content || null,
element.content || null, JSON.stringify(element.dataSource || {}),
JSON.stringify(element.dataSource || {}), JSON.stringify(element.chartConfig || {}),
JSON.stringify(element.chartConfig || {}), i,
i, now,
now, now,
now ]
]); );
} }
} }
return dashboardId; return dashboardId;
}); });
// 생성된 대시보드 반환 // 생성된 대시보드 반환
try { try {
const dashboard = await this.getDashboardById(dashboardId, userId); const dashboard = await this.getDashboardById(dashboardId, userId);
if (!dashboard) { if (!dashboard) {
console.error('대시보드 생성은 성공했으나 조회에 실패:', dashboardId); console.error("대시보드 생성은 성공했으나 조회에 실패:", dashboardId);
// 생성은 성공했으므로 기본 정보만이라도 반환 // 생성은 성공했으므로 기본 정보만이라도 반환
return { return {
id: dashboardId, id: dashboardId,
@ -99,13 +106,13 @@ export class DashboardService {
tags: data.tags || [], tags: data.tags || [],
category: data.category, category: data.category,
viewCount: 0, viewCount: 0,
elements: data.elements || [] elements: data.elements || [],
}; };
} }
return dashboard; return dashboard;
} catch (fetchError) { } catch (fetchError) {
console.error('생성된 대시보드 조회 중 오류:', fetchError); console.error("생성된 대시보드 조회 중 오류:", fetchError);
// 생성은 성공했으므로 기본 정보 반환 // 생성은 성공했으므로 기본 정보 반환
return { return {
id: dashboardId, id: dashboardId,
@ -119,76 +126,79 @@ export class DashboardService {
tags: data.tags || [], tags: data.tags || [],
category: data.category, category: data.category,
viewCount: 0, viewCount: 0,
elements: data.elements || [] elements: data.elements || [],
}; };
} }
} catch (error) { } catch (error) {
console.error('Dashboard creation error:', error); console.error("Dashboard creation error:", error);
throw error; throw error;
} }
} }
/** /**
* *
*/ */
static async getDashboards(query: DashboardListQuery, userId?: string) { static async getDashboards(query: DashboardListQuery, userId?: string) {
const { const {
page = 1, page = 1,
limit = 20, limit = 20,
search, search,
category, category,
isPublic, isPublic,
createdBy createdBy,
} = query; } = query;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
try { try {
// 기본 WHERE 조건 // 기본 WHERE 조건
let whereConditions = ['d.deleted_at IS NULL']; let whereConditions = ["d.deleted_at IS NULL"];
let params: any[] = []; let params: any[] = [];
let paramIndex = 1; let paramIndex = 1;
// 권한 필터링 // 권한 필터링
if (userId) { if (userId) {
whereConditions.push(`(d.created_by = $${paramIndex} OR d.is_public = true)`); whereConditions.push(
`(d.created_by = $${paramIndex} OR d.is_public = true)`
);
params.push(userId); params.push(userId);
paramIndex++; paramIndex++;
} else { } else {
whereConditions.push('d.is_public = true'); whereConditions.push("d.is_public = true");
} }
// 검색 조건 // 검색 조건
if (search) { if (search) {
whereConditions.push(`(d.title ILIKE $${paramIndex} OR d.description ILIKE $${paramIndex + 1})`); whereConditions.push(
`(d.title ILIKE $${paramIndex} OR d.description ILIKE $${paramIndex + 1})`
);
params.push(`%${search}%`, `%${search}%`); params.push(`%${search}%`, `%${search}%`);
paramIndex += 2; paramIndex += 2;
} }
// 카테고리 필터 // 카테고리 필터
if (category) { if (category) {
whereConditions.push(`d.category = $${paramIndex}`); whereConditions.push(`d.category = $${paramIndex}`);
params.push(category); params.push(category);
paramIndex++; paramIndex++;
} }
// 공개/비공개 필터 // 공개/비공개 필터
if (typeof isPublic === 'boolean') { if (typeof isPublic === "boolean") {
whereConditions.push(`d.is_public = $${paramIndex}`); whereConditions.push(`d.is_public = $${paramIndex}`);
params.push(isPublic); params.push(isPublic);
paramIndex++; paramIndex++;
} }
// 작성자 필터 // 작성자 필터
if (createdBy) { if (createdBy) {
whereConditions.push(`d.created_by = $${paramIndex}`); whereConditions.push(`d.created_by = $${paramIndex}`);
params.push(createdBy); params.push(createdBy);
paramIndex++; paramIndex++;
} }
const whereClause = whereConditions.join(' AND '); const whereClause = whereConditions.join(" AND ");
// 대시보드 목록 조회 (users 테이블 조인 제거) // 대시보드 목록 조회 (users 테이블 조인 제거)
const dashboardQuery = ` const dashboardQuery = `
SELECT SELECT
@ -213,22 +223,23 @@ export class DashboardService {
ORDER BY d.updated_at DESC ORDER BY d.updated_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`; `;
const dashboardResult = await PostgreSQLService.query( const dashboardResult = await PostgreSQLService.query(dashboardQuery, [
dashboardQuery, ...params,
[...params, limit, offset] limit,
); offset,
]);
// 전체 개수 조회 // 전체 개수 조회
const countQuery = ` const countQuery = `
SELECT COUNT(DISTINCT d.id) as total SELECT COUNT(DISTINCT d.id) as total
FROM dashboards d FROM dashboards d
WHERE ${whereClause} WHERE ${whereClause}
`; `;
const countResult = await PostgreSQLService.query(countQuery, params); const countResult = await PostgreSQLService.query(countQuery, params);
const total = parseInt(countResult.rows[0]?.total || '0'); const total = parseInt(countResult.rows[0]?.total || "0");
return { return {
dashboards: dashboardResult.rows.map((row: any) => ({ dashboards: dashboardResult.rows.map((row: any) => ({
id: row.id, id: row.id,
@ -239,33 +250,36 @@ export class DashboardService {
createdBy: row.created_by, createdBy: row.created_by,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
tags: JSON.parse(row.tags || '[]'), tags: JSON.parse(row.tags || "[]"),
category: row.category, category: row.category,
viewCount: parseInt(row.view_count || '0'), viewCount: parseInt(row.view_count || "0"),
elementsCount: parseInt(row.elements_count || '0') elementsCount: parseInt(row.elements_count || "0"),
})), })),
pagination: { pagination: {
page, page,
limit, limit,
total, total,
totalPages: Math.ceil(total / limit) totalPages: Math.ceil(total / limit),
} },
}; };
} catch (error) { } catch (error) {
console.error('Dashboard list error:', error); console.error("Dashboard list error:", error);
throw error; throw error;
} }
} }
/** /**
* *
*/ */
static async getDashboardById(dashboardId: string, userId?: string): Promise<Dashboard | null> { static async getDashboardById(
dashboardId: string,
userId?: string
): Promise<Dashboard | null> {
try { try {
// 1. 대시보드 기본 정보 조회 (권한 체크 포함) // 1. 대시보드 기본 정보 조회 (권한 체크 포함)
let dashboardQuery: string; let dashboardQuery: string;
let dashboardParams: any[]; let dashboardParams: any[];
if (userId) { if (userId) {
dashboardQuery = ` dashboardQuery = `
SELECT d.* SELECT d.*
@ -283,53 +297,50 @@ export class DashboardService {
`; `;
dashboardParams = [dashboardId]; dashboardParams = [dashboardId];
} }
const dashboardResult = await PostgreSQLService.query(dashboardQuery, dashboardParams); const dashboardResult = await PostgreSQLService.query(
dashboardQuery,
dashboardParams
);
if (dashboardResult.rows.length === 0) { if (dashboardResult.rows.length === 0) {
return null; return null;
} }
const dashboard = dashboardResult.rows[0]; const dashboard = dashboardResult.rows[0];
// 2. 대시보드 요소들 조회 // 2. 대시보드 요소들 조회
const elementsQuery = ` const elementsQuery = `
SELECT * FROM dashboard_elements SELECT * FROM dashboard_elements
WHERE dashboard_id = $1 WHERE dashboard_id = $1
ORDER BY display_order ASC ORDER BY display_order ASC
`; `;
const elementsResult = await PostgreSQLService.query(elementsQuery, [dashboardId]); const elementsResult = await PostgreSQLService.query(elementsQuery, [
dashboardId,
]);
// 3. 요소 데이터 변환 // 3. 요소 데이터 변환
console.log('📊 대시보드 요소 개수:', elementsResult.rows.length); const elements: DashboardElement[] = elementsResult.rows.map(
(row: any) => ({
const elements: DashboardElement[] = elementsResult.rows.map((row: any, index: number) => {
const element = {
id: row.id, id: row.id,
type: row.element_type, type: row.element_type,
subtype: row.element_subtype, subtype: row.element_subtype,
position: { position: {
x: row.position_x, x: row.position_x,
y: row.position_y y: row.position_y,
}, },
size: { size: {
width: row.width, width: row.width,
height: row.height height: row.height,
}, },
title: row.title, title: row.title,
customTitle: row.custom_title,
showHeader: row.show_header !== false,
content: row.content, content: row.content,
dataSource: JSON.parse(row.data_source_config || '{}'), dataSource: JSON.parse(row.data_source_config || "{}"),
chartConfig: JSON.parse(row.chart_config || '{}') chartConfig: JSON.parse(row.chart_config || "{}"),
}; })
);
console.log(`📊 위젯 #${index + 1}: type="${element.type}", subtype="${element.subtype}", title="${element.title}"`);
return element;
});
return { return {
id: dashboard.id, id: dashboard.id,
title: dashboard.title, title: dashboard.title,
@ -339,44 +350,48 @@ export class DashboardService {
createdBy: dashboard.created_by, createdBy: dashboard.created_by,
createdAt: dashboard.created_at, createdAt: dashboard.created_at,
updatedAt: dashboard.updated_at, updatedAt: dashboard.updated_at,
tags: JSON.parse(dashboard.tags || '[]'), tags: JSON.parse(dashboard.tags || "[]"),
category: dashboard.category, category: dashboard.category,
viewCount: parseInt(dashboard.view_count || '0'), viewCount: parseInt(dashboard.view_count || "0"),
elements settings: dashboard.settings || undefined,
elements,
}; };
} catch (error) { } catch (error) {
console.error('Dashboard get error:', error); console.error("Dashboard get error:", error);
throw error; throw error;
} }
} }
/** /**
* *
*/ */
static async updateDashboard( static async updateDashboard(
dashboardId: string, dashboardId: string,
data: UpdateDashboardRequest, data: UpdateDashboardRequest,
userId: string userId: string
): Promise<Dashboard | null> { ): Promise<Dashboard | null> {
try { try {
const result = await PostgreSQLService.transaction(async (client) => { const result = await PostgreSQLService.transaction(async (client) => {
// 권한 체크 // 권한 체크
const authCheckResult = await client.query(` const authCheckResult = await client.query(
`
SELECT id FROM dashboards SELECT id FROM dashboards
WHERE id = $1 AND created_by = $2 AND deleted_at IS NULL WHERE id = $1 AND created_by = $2 AND deleted_at IS NULL
`, [dashboardId, userId]); `,
[dashboardId, userId]
);
if (authCheckResult.rows.length === 0) { if (authCheckResult.rows.length === 0) {
throw new Error('대시보드를 찾을 수 없거나 수정 권한이 없습니다.'); throw new Error("대시보드를 찾을 수 없거나 수정 권한이 없습니다.");
} }
const now = new Date(); const now = new Date();
// 1. 대시보드 메인 정보 업데이트 // 1. 대시보드 메인 정보 업데이트
const updateFields: string[] = []; const updateFields: string[] = [];
const updateParams: any[] = []; const updateParams: any[] = [];
let paramIndex = 1; let paramIndex = 1;
if (data.title !== undefined) { if (data.title !== undefined) {
updateFields.push(`title = $${paramIndex}`); updateFields.push(`title = $${paramIndex}`);
updateParams.push(data.title); updateParams.push(data.title);
@ -402,122 +417,141 @@ export class DashboardService {
updateParams.push(data.category); updateParams.push(data.category);
paramIndex++; paramIndex++;
} }
if (data.settings !== undefined) {
updateFields.push(`settings = $${paramIndex}`);
updateParams.push(JSON.stringify(data.settings));
paramIndex++;
}
updateFields.push(`updated_at = $${paramIndex}`); updateFields.push(`updated_at = $${paramIndex}`);
updateParams.push(now); updateParams.push(now);
paramIndex++; paramIndex++;
updateParams.push(dashboardId); updateParams.push(dashboardId);
if (updateFields.length > 1) { // updated_at 외에 다른 필드가 있는 경우 if (updateFields.length > 1) {
// updated_at 외에 다른 필드가 있는 경우
const updateQuery = ` const updateQuery = `
UPDATE dashboards UPDATE dashboards
SET ${updateFields.join(', ')} SET ${updateFields.join(", ")}
WHERE id = $${paramIndex} WHERE id = $${paramIndex}
`; `;
await client.query(updateQuery, updateParams); await client.query(updateQuery, updateParams);
} }
// 2. 요소 업데이트 (있는 경우) // 2. 요소 업데이트 (있는 경우)
if (data.elements) { if (data.elements) {
// 기존 요소들 삭제 // 기존 요소들 삭제
await client.query(` await client.query(
`
DELETE FROM dashboard_elements WHERE dashboard_id = $1 DELETE FROM dashboard_elements WHERE dashboard_id = $1
`, [dashboardId]); `,
[dashboardId]
);
// 새 요소들 추가 // 새 요소들 추가
for (let i = 0; i < data.elements.length; i++) { for (let i = 0; i < data.elements.length; i++) {
const element = data.elements[i]; const element = data.elements[i];
const elementId = uuidv4(); const elementId = uuidv4();
await client.query(` await client.query(
`
INSERT INTO dashboard_elements ( INSERT INTO dashboard_elements (
id, dashboard_id, element_type, element_subtype, id, dashboard_id, element_type, element_subtype,
position_x, position_y, width, height, position_x, position_y, width, height,
title, custom_title, show_header, content, data_source_config, chart_config, title, content, data_source_config, chart_config,
display_order, created_at, updated_at display_order, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
`, [ `,
elementId, [
dashboardId, elementId,
element.type, dashboardId,
element.subtype, element.type,
element.position.x, element.subtype,
element.position.y, element.position.x,
element.size.width, element.position.y,
element.size.height, element.size.width,
element.title, element.size.height,
element.customTitle || null, element.title,
element.showHeader !== false, element.content || null,
element.content || null, JSON.stringify(element.dataSource || {}),
JSON.stringify(element.dataSource || {}), JSON.stringify(element.chartConfig || {}),
JSON.stringify(element.chartConfig || {}), i,
i, now,
now, now,
now ]
]); );
} }
} }
return dashboardId; return dashboardId;
}); });
// 업데이트된 대시보드 반환 // 업데이트된 대시보드 반환
return await this.getDashboardById(dashboardId, userId); return await this.getDashboardById(dashboardId, userId);
} catch (error) { } catch (error) {
console.error('Dashboard update error:', error); console.error("Dashboard update error:", error);
throw error; throw error;
} }
} }
/** /**
* ( ) * ( )
*/ */
static async deleteDashboard(dashboardId: string, userId: string): Promise<boolean> { static async deleteDashboard(
dashboardId: string,
userId: string
): Promise<boolean> {
try { try {
const now = new Date(); const now = new Date();
const result = await PostgreSQLService.query(` const result = await PostgreSQLService.query(
`
UPDATE dashboards UPDATE dashboards
SET deleted_at = $1, updated_at = $2 SET deleted_at = $1, updated_at = $2
WHERE id = $3 AND created_by = $4 AND deleted_at IS NULL WHERE id = $3 AND created_by = $4 AND deleted_at IS NULL
`, [now, now, dashboardId, userId]); `,
[now, now, dashboardId, userId]
);
return (result.rowCount || 0) > 0; return (result.rowCount || 0) > 0;
} catch (error) { } catch (error) {
console.error('Dashboard delete error:', error); console.error("Dashboard delete error:", error);
throw error; throw error;
} }
} }
/** /**
* *
*/ */
static async incrementViewCount(dashboardId: string): Promise<void> { static async incrementViewCount(dashboardId: string): Promise<void> {
try { try {
await PostgreSQLService.query(` await PostgreSQLService.query(
`
UPDATE dashboards UPDATE dashboards
SET view_count = view_count + 1 SET view_count = view_count + 1
WHERE id = $1 AND deleted_at IS NULL WHERE id = $1 AND deleted_at IS NULL
`, [dashboardId]); `,
[dashboardId]
);
} catch (error) { } catch (error) {
console.error('View count increment error:', error); console.error("View count increment error:", error);
// 조회수 증가 실패는 치명적이지 않으므로 에러를 던지지 않음 // 조회수 증가 실패는 치명적이지 않으므로 에러를 던지지 않음
} }
} }
/** /**
* *
*/ */
static async checkUserPermission( static async checkUserPermission(
dashboardId: string, dashboardId: string,
userId: string, userId: string,
requiredPermission: 'view' | 'edit' | 'admin' = 'view' requiredPermission: "view" | "edit" | "admin" = "view"
): Promise<boolean> { ): Promise<boolean> {
try { try {
const result = await PostgreSQLService.query(` const result = await PostgreSQLService.query(
`
SELECT SELECT
CASE CASE
WHEN d.created_by = $2 THEN 'admin' WHEN d.created_by = $2 THEN 'admin'
@ -526,23 +560,26 @@ export class DashboardService {
END as permission END as permission
FROM dashboards d FROM dashboards d
WHERE d.id = $1 AND d.deleted_at IS NULL WHERE d.id = $1 AND d.deleted_at IS NULL
`, [dashboardId, userId]); `,
[dashboardId, userId]
);
if (result.rows.length === 0) { if (result.rows.length === 0) {
return false; return false;
} }
const userPermission = result.rows[0].permission; const userPermission = result.rows[0].permission;
// 권한 레벨 체크 // 권한 레벨 체크
const permissionLevels = { 'view': 1, 'edit': 2, 'admin': 3 }; const permissionLevels = { view: 1, edit: 2, admin: 3 };
const userLevel = permissionLevels[userPermission as keyof typeof permissionLevels] || 0; const userLevel =
permissionLevels[userPermission as keyof typeof permissionLevels] || 0;
const requiredLevel = permissionLevels[requiredPermission]; const requiredLevel = permissionLevels[requiredPermission];
return userLevel >= requiredLevel; return userLevel >= requiredLevel;
} catch (error) { } catch (error) {
console.error('Permission check error:', error); console.error("Permission check error:", error);
return false; return false;
} }
} }
} }

View File

@ -53,13 +53,20 @@ export class BookingService {
} }
private ensureDataDirectory(): void { private ensureDataDirectory(): void {
if (!fs.existsSync(BOOKING_DIR)) { try {
fs.mkdirSync(BOOKING_DIR, { recursive: true }); if (!fs.existsSync(BOOKING_DIR)) {
logger.info(`📁 예약 데이터 디렉토리 생성: ${BOOKING_DIR}`); fs.mkdirSync(BOOKING_DIR, { recursive: true, mode: 0o755 });
} logger.info(`📁 예약 데이터 디렉토리 생성: ${BOOKING_DIR}`);
if (!fs.existsSync(BOOKING_FILE)) { }
fs.writeFileSync(BOOKING_FILE, JSON.stringify([], null, 2)); if (!fs.existsSync(BOOKING_FILE)) {
logger.info(`📄 예약 파일 생성: ${BOOKING_FILE}`); fs.writeFileSync(BOOKING_FILE, JSON.stringify([], null, 2), {
mode: 0o644,
});
logger.info(`📄 예약 파일 생성: ${BOOKING_FILE}`);
}
} catch (error) {
logger.error(`❌ 예약 디렉토리 생성 실패: ${BOOKING_DIR}`, error);
throw error;
} }
} }
@ -111,13 +118,16 @@ export class BookingService {
priority?: string; priority?: string;
}): Promise<{ bookings: BookingRequest[]; newCount: number }> { }): Promise<{ bookings: BookingRequest[]; newCount: number }> {
try { try {
const bookings = DATA_SOURCE === "database" const bookings =
? await this.loadBookingsFromDB(filter) DATA_SOURCE === "database"
: this.loadBookingsFromFile(filter); ? await this.loadBookingsFromDB(filter)
: this.loadBookingsFromFile(filter);
bookings.sort((a, b) => { bookings.sort((a, b) => {
if (a.priority !== b.priority) return a.priority === "urgent" ? -1 : 1; if (a.priority !== b.priority) return a.priority === "urgent" ? -1 : 1;
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); return (
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}); });
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
@ -145,7 +155,10 @@ export class BookingService {
} }
} }
public async rejectBooking(id: string, reason?: string): Promise<BookingRequest> { public async rejectBooking(
id: string,
reason?: string
): Promise<BookingRequest> {
try { try {
if (DATA_SOURCE === "database") { if (DATA_SOURCE === "database") {
return await this.rejectBookingDB(id, reason); return await this.rejectBookingDB(id, reason);
@ -194,9 +207,15 @@ export class BookingService {
scheduledTime: new Date(row.scheduledTime).toISOString(), scheduledTime: new Date(row.scheduledTime).toISOString(),
createdAt: new Date(row.createdAt).toISOString(), createdAt: new Date(row.createdAt).toISOString(),
updatedAt: new Date(row.updatedAt).toISOString(), updatedAt: new Date(row.updatedAt).toISOString(),
acceptedAt: row.acceptedAt ? new Date(row.acceptedAt).toISOString() : undefined, acceptedAt: row.acceptedAt
rejectedAt: row.rejectedAt ? new Date(row.rejectedAt).toISOString() : undefined, ? new Date(row.acceptedAt).toISOString()
completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, : undefined,
rejectedAt: row.rejectedAt
? new Date(row.rejectedAt).toISOString()
: undefined,
completedAt: row.completedAt
? new Date(row.completedAt).toISOString()
: undefined,
})); }));
} }
@ -230,7 +249,10 @@ export class BookingService {
}; };
} }
private async rejectBookingDB(id: string, reason?: string): Promise<BookingRequest> { private async rejectBookingDB(
id: string,
reason?: string
): Promise<BookingRequest> {
const rows = await query( const rows = await query(
`UPDATE booking_requests `UPDATE booking_requests
SET status = 'rejected', rejected_at = NOW(), updated_at = NOW(), rejection_reason = $2 SET status = 'rejected', rejected_at = NOW(), updated_at = NOW(), rejection_reason = $2

View File

@ -33,11 +33,7 @@ class MailAccountFileService {
try { try {
await fs.access(this.accountsDir); await fs.access(this.accountsDir);
} catch { } catch {
try { await fs.mkdir(this.accountsDir, { recursive: true, mode: 0o755 });
await fs.mkdir(this.accountsDir, { recursive: true });
} catch (error) {
console.error("메일 계정 디렉토리 생성 실패:", error);
}
} }
} }

View File

@ -59,11 +59,7 @@ export class MailReceiveBasicService {
try { try {
await fs.access(this.attachmentsDir); await fs.access(this.attachmentsDir);
} catch { } catch {
try { await fs.mkdir(this.attachmentsDir, { recursive: true, mode: 0o755 });
await fs.mkdir(this.attachmentsDir, { recursive: true });
} catch (error) {
console.error("메일 첨부파일 디렉토리 생성 실패:", error);
}
} }
} }

View File

@ -20,15 +20,13 @@ const SENT_MAIL_DIR =
class MailSentHistoryService { class MailSentHistoryService {
constructor() { constructor() {
// 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지
try { try {
if (!fs.existsSync(SENT_MAIL_DIR)) { if (!fs.existsSync(SENT_MAIL_DIR)) {
fs.mkdirSync(SENT_MAIL_DIR, { recursive: true }); fs.mkdirSync(SENT_MAIL_DIR, { recursive: true, mode: 0o755 });
} }
} catch (error) { } catch (error) {
console.error("메일 발송 이력 디렉토리 생성 실패:", error); console.error("메일 발송 이력 디렉토리 생성 실패:", error);
// 디렉토리가 이미 존재하거나 권한이 없어도 서비스는 계속 실행 throw error;
// 실제 파일 쓰기 시점에 에러 처리
} }
} }
@ -45,13 +43,15 @@ class MailSentHistoryService {
}; };
try { try {
// 디렉토리가 없으면 다시 시도
if (!fs.existsSync(SENT_MAIL_DIR)) { if (!fs.existsSync(SENT_MAIL_DIR)) {
fs.mkdirSync(SENT_MAIL_DIR, { recursive: true }); fs.mkdirSync(SENT_MAIL_DIR, { recursive: true, mode: 0o755 });
} }
const filePath = path.join(SENT_MAIL_DIR, `${history.id}.json`); const filePath = path.join(SENT_MAIL_DIR, `${history.id}.json`);
fs.writeFileSync(filePath, JSON.stringify(history, null, 2), "utf-8"); fs.writeFileSync(filePath, JSON.stringify(history, null, 2), {
encoding: "utf-8",
mode: 0o644,
});
console.log("발송 이력 저장:", history.id); console.log("발송 이력 저장:", history.id);
} catch (error) { } catch (error) {

View File

@ -54,17 +54,13 @@ class MailTemplateFileService {
} }
/** /**
* 릿 () - try-catch로 * 릿
*/ */
private async ensureDirectoryExists() { private async ensureDirectoryExists() {
try { try {
await fs.access(this.templatesDir); await fs.access(this.templatesDir);
} catch { } catch {
try { await fs.mkdir(this.templatesDir, { recursive: true, mode: 0o755 });
await fs.mkdir(this.templatesDir, { recursive: true });
} catch (error) {
console.error("메일 템플릿 디렉토리 생성 실패:", error);
}
} }
} }

View File

@ -61,13 +61,20 @@ export class TodoService {
* ( ) * ( )
*/ */
private ensureDataDirectory(): void { private ensureDataDirectory(): void {
if (!fs.existsSync(TODO_DIR)) { try {
fs.mkdirSync(TODO_DIR, { recursive: true }); if (!fs.existsSync(TODO_DIR)) {
logger.info(`📁 To-Do 데이터 디렉토리 생성: ${TODO_DIR}`); fs.mkdirSync(TODO_DIR, { recursive: true, mode: 0o755 });
} logger.info(`📁 To-Do 데이터 디렉토리 생성: ${TODO_DIR}`);
if (!fs.existsSync(TODO_FILE)) { }
fs.writeFileSync(TODO_FILE, JSON.stringify([], null, 2)); if (!fs.existsSync(TODO_FILE)) {
logger.info(`📄 To-Do 파일 생성: ${TODO_FILE}`); fs.writeFileSync(TODO_FILE, JSON.stringify([], null, 2), {
mode: 0o644,
});
logger.info(`📄 To-Do 파일 생성: ${TODO_FILE}`);
}
} catch (error) {
logger.error(`❌ To-Do 디렉토리 생성 실패: ${TODO_DIR}`, error);
throw error;
} }
} }
@ -80,15 +87,17 @@ export class TodoService {
assignedTo?: string; assignedTo?: string;
}): Promise<TodoListResponse> { }): Promise<TodoListResponse> {
try { try {
const todos = DATA_SOURCE === "database" const todos =
? await this.loadTodosFromDB(filter) DATA_SOURCE === "database"
: this.loadTodosFromFile(filter); ? await this.loadTodosFromDB(filter)
: this.loadTodosFromFile(filter);
// 정렬: 긴급 > 우선순위 > 순서 // 정렬: 긴급 > 우선순위 > 순서
todos.sort((a, b) => { todos.sort((a, b) => {
if (a.isUrgent !== b.isUrgent) return a.isUrgent ? -1 : 1; if (a.isUrgent !== b.isUrgent) return a.isUrgent ? -1 : 1;
const priorityOrder = { urgent: 0, high: 1, normal: 2, low: 3 }; const priorityOrder = { urgent: 0, high: 1, normal: 2, low: 3 };
if (a.priority !== b.priority) return priorityOrder[a.priority] - priorityOrder[b.priority]; if (a.priority !== b.priority)
return priorityOrder[a.priority] - priorityOrder[b.priority];
return a.order - b.order; return a.order - b.order;
}); });
@ -124,7 +133,8 @@ export class TodoService {
await this.createTodoDB(newTodo); await this.createTodoDB(newTodo);
} else { } else {
const todos = this.loadTodosFromFile(); const todos = this.loadTodosFromFile();
newTodo.order = todos.length > 0 ? Math.max(...todos.map((t) => t.order)) + 1 : 0; newTodo.order =
todos.length > 0 ? Math.max(...todos.map((t) => t.order)) + 1 : 0;
todos.push(newTodo); todos.push(newTodo);
this.saveTodosToFile(todos); this.saveTodosToFile(todos);
} }
@ -140,7 +150,10 @@ export class TodoService {
/** /**
* To-Do * To-Do
*/ */
public async updateTodo(id: string, updates: Partial<TodoItem>): Promise<TodoItem> { public async updateTodo(
id: string,
updates: Partial<TodoItem>
): Promise<TodoItem> {
try { try {
if (DATA_SOURCE === "database") { if (DATA_SOURCE === "database") {
return await this.updateTodoDB(id, updates); return await this.updateTodoDB(id, updates);
@ -231,7 +244,9 @@ export class TodoService {
dueDate: row.dueDate ? new Date(row.dueDate).toISOString() : undefined, dueDate: row.dueDate ? new Date(row.dueDate).toISOString() : undefined,
createdAt: new Date(row.createdAt).toISOString(), createdAt: new Date(row.createdAt).toISOString(),
updatedAt: new Date(row.updatedAt).toISOString(), updatedAt: new Date(row.updatedAt).toISOString(),
completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, completedAt: row.completedAt
? new Date(row.completedAt).toISOString()
: undefined,
})); }));
} }
@ -263,7 +278,10 @@ export class TodoService {
); );
} }
private async updateTodoDB(id: string, updates: Partial<TodoItem>): Promise<TodoItem> { private async updateTodoDB(
id: string,
updates: Partial<TodoItem>
): Promise<TodoItem> {
const setClauses: string[] = ["updated_at = NOW()"]; const setClauses: string[] = ["updated_at = NOW()"];
const params: any[] = []; const params: any[] = [];
let paramIndex = 1; let paramIndex = 1;
@ -327,12 +345,17 @@ export class TodoService {
dueDate: row.dueDate ? new Date(row.dueDate).toISOString() : undefined, dueDate: row.dueDate ? new Date(row.dueDate).toISOString() : undefined,
createdAt: new Date(row.createdAt).toISOString(), createdAt: new Date(row.createdAt).toISOString(),
updatedAt: new Date(row.updatedAt).toISOString(), updatedAt: new Date(row.updatedAt).toISOString(),
completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, completedAt: row.completedAt
? new Date(row.completedAt).toISOString()
: undefined,
}; };
} }
private async deleteTodoDB(id: string): Promise<void> { private async deleteTodoDB(id: string): Promise<void> {
const rows = await query("DELETE FROM todo_items WHERE id = $1 RETURNING id", [id]); const rows = await query(
"DELETE FROM todo_items WHERE id = $1 RETURNING id",
[id]
);
if (rows.length === 0) { if (rows.length === 0) {
throw new Error(`To-Do 항목을 찾을 수 없습니다: ${id}`); throw new Error(`To-Do 항목을 찾을 수 없습니다: ${id}`);
} }
@ -443,7 +466,10 @@ export class TodoService {
inProgress: todos.filter((t) => t.status === "in_progress").length, inProgress: todos.filter((t) => t.status === "in_progress").length,
completed: todos.filter((t) => t.status === "completed").length, completed: todos.filter((t) => t.status === "completed").length,
urgent: todos.filter((t) => t.isUrgent).length, urgent: todos.filter((t) => t.isUrgent).length,
overdue: todos.filter((t) => t.dueDate && new Date(t.dueDate) < now && t.status !== "completed").length, overdue: todos.filter(
(t) =>
t.dueDate && new Date(t.dueDate) < now && t.status !== "completed"
).length,
}; };
} }
} }

View File

@ -4,8 +4,8 @@
export interface DashboardElement { export interface DashboardElement {
id: string; id: string;
type: 'chart' | 'widget'; type: "chart" | "widget";
subtype: 'bar' | 'pie' | 'line' | 'exchange' | 'weather'; subtype: "bar" | "pie" | "line" | "exchange" | "weather";
position: { position: {
x: number; x: number;
y: number; y: number;
@ -19,7 +19,7 @@ export interface DashboardElement {
showHeader?: boolean; // 헤더 표시 여부 (기본값: true) showHeader?: boolean; // 헤더 표시 여부 (기본값: true)
content?: string; content?: string;
dataSource?: { dataSource?: {
type: 'api' | 'database' | 'static'; type: "api" | "database" | "static";
endpoint?: string; endpoint?: string;
query?: string; query?: string;
refreshInterval?: number; refreshInterval?: number;
@ -30,7 +30,7 @@ export interface DashboardElement {
xAxis?: string; xAxis?: string;
yAxis?: string; yAxis?: string;
groupBy?: string; groupBy?: string;
aggregation?: 'sum' | 'avg' | 'count' | 'max' | 'min'; aggregation?: "sum" | "avg" | "count" | "max" | "min";
colors?: string[]; colors?: string[];
title?: string; title?: string;
showLegend?: boolean; showLegend?: boolean;
@ -50,6 +50,10 @@ export interface Dashboard {
tags?: string[]; tags?: string[];
category?: string; category?: string;
viewCount: number; viewCount: number;
settings?: {
resolution?: string;
backgroundColor?: string;
};
elements: DashboardElement[]; elements: DashboardElement[];
} }
@ -60,6 +64,10 @@ export interface CreateDashboardRequest {
elements: DashboardElement[]; elements: DashboardElement[];
tags?: string[]; tags?: string[];
category?: string; category?: string;
settings?: {
resolution?: string;
backgroundColor?: string;
};
} }
export interface UpdateDashboardRequest { export interface UpdateDashboardRequest {
@ -69,6 +77,10 @@ export interface UpdateDashboardRequest {
elements?: DashboardElement[]; elements?: DashboardElement[];
tags?: string[]; tags?: string[];
category?: string; category?: string;
settings?: {
resolution?: string;
backgroundColor?: string;
};
} }
export interface DashboardListQuery { export interface DashboardListQuery {
@ -85,7 +97,7 @@ export interface DashboardShare {
dashboardId: string; dashboardId: string;
sharedWithUser?: string; sharedWithUser?: string;
sharedWithRole?: string; sharedWithRole?: string;
permissionLevel: 'view' | 'edit' | 'admin'; permissionLevel: "view" | "edit" | "admin";
createdBy: string; createdBy: string;
createdAt: string; createdAt: string;
expiresAt?: string; expiresAt?: string;

View File

@ -34,14 +34,11 @@ COPY --from=build /app/dist ./dist
# Copy package files # Copy package files
COPY package*.json ./ COPY package*.json ./
# Create logs, uploads, and data directories and set permissions (use existing node user with UID 1000) # 루트 디렉토리만 생성하고 node 유저에게 쓰기 권한 부여
RUN mkdir -p logs \ # 하위 디렉토리는 애플리케이션이 런타임에 자동 생성
uploads/mail-attachments \ RUN mkdir -p logs uploads data && \
uploads/mail-templates \ chown -R node:node /app && \
uploads/mail-accounts \ chmod -R 755 /app
data/mail-sent && \
chown -R node:node logs uploads data && \
chmod -R 755 logs uploads data
EXPOSE 3001 EXPOSE 3001
USER node USER node

View File

@ -20,8 +20,8 @@ services:
LOG_LEVEL: info LOG_LEVEL: info
ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure
volumes: volumes:
- /home/vexplor/backend_data/uploads:/app/uploads - backend_uploads:/app/uploads
- /home/vexplor/backend_data/data:/app/data - backend_data:/app/data
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.backend.rule=Host(`api.vexplor.com`) - traefik.http.routers.backend.rule=Host(`api.vexplor.com`)
@ -46,7 +46,7 @@ services:
PORT: "3000" PORT: "3000"
HOSTNAME: 0.0.0.0 HOSTNAME: 0.0.0.0
volumes: volumes:
- /home/vexplor/frontend_data:/app/data - frontend_data:/app/data
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.frontend.rule=Host(`v1.vexplor.com`) - traefik.http.routers.frontend.rule=Host(`v1.vexplor.com`)
@ -55,6 +55,14 @@ services:
- traefik.http.routers.frontend.tls.certresolver=le - traefik.http.routers.frontend.tls.certresolver=le
- traefik.http.services.frontend.loadbalancer.server.port=3000 - traefik.http.services.frontend.loadbalancer.server.port=3000
volumes:
backend_uploads:
driver: local
backend_data:
driver: local
frontend_data:
driver: local
networks: networks:
default: default:
name: toktork_server_default name: toktork_server_default

View File

@ -37,8 +37,11 @@ COPY --from=build /app/dist ./dist
# Copy package files # Copy package files
COPY package*.json ./ COPY package*.json ./
# Create logs and uploads directories and set permissions # 루트 디렉토리만 생성하고 appuser에게 쓰기 권한 부여
RUN mkdir -p logs uploads && chown -R appuser:appgroup logs uploads && chmod -R 755 logs uploads # 하위 디렉토리는 애플리케이션이 런타임에 자동 생성
RUN mkdir -p logs uploads data && \
chown -R appuser:appgroup /app && \
chmod -R 755 /app
EXPOSE 8080 EXPOSE 8080
USER appuser USER appuser

View File

@ -156,8 +156,6 @@ export default function DashboardListPage() {
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead className="w-[80px]"></TableHead> <TableHead className="w-[80px]"></TableHead>
@ -166,29 +164,10 @@ export default function DashboardListPage() {
<TableBody> <TableBody>
{dashboards.map((dashboard) => ( {dashboards.map((dashboard) => (
<TableRow key={dashboard.id} className="cursor-pointer hover:bg-gray-50"> <TableRow key={dashboard.id} className="cursor-pointer hover:bg-gray-50">
<TableCell className="font-medium"> <TableCell className="font-medium">{dashboard.title}</TableCell>
<div className="flex items-center gap-2">
{dashboard.title}
{dashboard.isPublic && (
<Badge variant="outline" className="text-xs">
</Badge>
)}
</div>
</TableCell>
<TableCell className="max-w-md truncate text-sm text-gray-500"> <TableCell className="max-w-md truncate text-sm text-gray-500">
{dashboard.description || "-"} {dashboard.description || "-"}
</TableCell> </TableCell>
<TableCell>
<Badge variant="secondary">{dashboard.elementsCount || 0}</Badge>
</TableCell>
<TableCell>
{dashboard.isPublic ? (
<Badge className="bg-green-100 text-green-800"></Badge>
) : (
<Badge variant="outline"></Badge>
)}
</TableCell>
<TableCell className="text-sm text-gray-500">{formatDate(dashboard.createdAt)}</TableCell> <TableCell className="text-sm text-gray-500">{formatDate(dashboard.createdAt)}</TableCell>
<TableCell className="text-sm text-gray-500">{formatDate(dashboard.updatedAt)}</TableCell> <TableCell className="text-sm text-gray-500">{formatDate(dashboard.updatedAt)}</TableCell>
<TableCell> <TableCell>

View File

@ -110,6 +110,7 @@ interface CanvasElementProps {
element: DashboardElement; element: DashboardElement;
isSelected: boolean; isSelected: boolean;
cellSize: number; cellSize: number;
canvasWidth?: number;
onUpdate: (id: string, updates: Partial<DashboardElement>) => void; onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
onRemove: (id: string) => void; onRemove: (id: string) => void;
onSelect: (id: string | null) => void; onSelect: (id: string | null) => void;
@ -126,6 +127,7 @@ export function CanvasElement({
element, element,
isSelected, isSelected,
cellSize, cellSize,
canvasWidth = 1560,
onUpdate, onUpdate,
onRemove, onRemove,
onSelect, onSelect,
@ -164,7 +166,11 @@ export function CanvasElement({
return; return;
} }
onSelect(element.id); // 선택되지 않은 경우에만 선택 처리
if (!isSelected) {
onSelect(element.id);
}
setIsDragging(true); setIsDragging(true);
setDragStart({ setDragStart({
x: e.clientX, x: e.clientX,
@ -174,7 +180,7 @@ export function CanvasElement({
}); });
e.preventDefault(); e.preventDefault();
}, },
[element.id, element.position.x, element.position.y, onSelect], [element.id, element.position.x, element.position.y, onSelect, isSelected],
); );
// 리사이즈 핸들 마우스다운 // 리사이즈 핸들 마우스다운
@ -207,7 +213,7 @@ export function CanvasElement({
const rawY = Math.max(0, dragStart.elementY + deltaY); const rawY = Math.max(0, dragStart.elementY + deltaY);
// X 좌표가 캔버스 너비를 벗어나지 않도록 제한 // X 좌표가 캔버스 너비를 벗어나지 않도록 제한
const maxX = GRID_CONFIG.CANVAS_WIDTH - element.size.width; const maxX = canvasWidth - element.size.width;
rawX = Math.min(rawX, maxX); rawX = Math.min(rawX, maxX);
setTempPosition({ x: rawX, y: rawY }); setTempPosition({ x: rawX, y: rawY });
@ -223,8 +229,8 @@ export function CanvasElement({
// 최소 크기 설정: 달력은 2x3, 나머지는 2x2 // 최소 크기 설정: 달력은 2x3, 나머지는 2x2
const minWidthCells = 2; const minWidthCells = 2;
const minHeightCells = element.type === "widget" && element.subtype === "calendar" ? 3 : 2; const minHeightCells = element.type === "widget" && element.subtype === "calendar" ? 3 : 2;
const minWidth = GRID_CONFIG.CELL_SIZE * minWidthCells; const minWidth = cellSize * minWidthCells;
const minHeight = GRID_CONFIG.CELL_SIZE * minHeightCells; const minHeight = cellSize * minHeightCells;
switch (resizeStart.handle) { switch (resizeStart.handle) {
case "se": // 오른쪽 아래 case "se": // 오른쪽 아래
@ -250,7 +256,7 @@ export function CanvasElement({
} }
// 가로 너비가 캔버스를 벗어나지 않도록 제한 // 가로 너비가 캔버스를 벗어나지 않도록 제한
const maxWidth = GRID_CONFIG.CANVAS_WIDTH - newX; const maxWidth = canvasWidth - newX;
newWidth = Math.min(newWidth, maxWidth); newWidth = Math.min(newWidth, maxWidth);
// 임시 크기/위치 저장 (스냅 안 됨) // 임시 크기/위치 저장 (스냅 안 됨)
@ -258,7 +264,7 @@ export function CanvasElement({
setTempSize({ width: newWidth, height: newHeight }); setTempSize({ width: newWidth, height: newHeight });
} }
}, },
[isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype], [isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype, canvasWidth],
); );
// 마우스 업 처리 (그리드 스냅 적용) // 마우스 업 처리 (그리드 스냅 적용)
@ -269,7 +275,7 @@ export function CanvasElement({
const snappedY = snapToGrid(tempPosition.y, cellSize); const snappedY = snapToGrid(tempPosition.y, cellSize);
// X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한 // X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한
const maxX = GRID_CONFIG.CANVAS_WIDTH - element.size.width; const maxX = canvasWidth - element.size.width;
snappedX = Math.min(snappedX, maxX); snappedX = Math.min(snappedX, maxX);
onUpdate(element.id, { onUpdate(element.id, {
@ -287,7 +293,7 @@ export function CanvasElement({
const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize); const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize);
// 가로 너비가 캔버스를 벗어나지 않도록 최종 제한 // 가로 너비가 캔버스를 벗어나지 않도록 최종 제한
const maxWidth = GRID_CONFIG.CANVAS_WIDTH - snappedX; const maxWidth = canvasWidth - snappedX;
snappedWidth = Math.min(snappedWidth, maxWidth); snappedWidth = Math.min(snappedWidth, maxWidth);
onUpdate(element.id, { onUpdate(element.id, {
@ -301,7 +307,7 @@ export function CanvasElement({
setIsDragging(false); setIsDragging(false);
setIsResizing(false); setIsResizing(false);
}, [isDragging, isResizing, tempPosition, tempSize, element.id, element.size.width, onUpdate, cellSize]); }, [isDragging, isResizing, tempPosition, tempSize, element.id, element.size.width, onUpdate, cellSize, canvasWidth]);
// 전역 마우스 이벤트 등록 // 전역 마우스 이벤트 등록
React.useEffect(() => { React.useEffect(() => {
@ -545,12 +551,7 @@ export function CanvasElement({
) : element.type === "widget" && element.subtype === "status-summary" ? ( ) : element.type === "widget" && element.subtype === "status-summary" ? (
// 커스텀 상태 카드 - 범용 위젯 // 커스텀 상태 카드 - 범용 위젯
<div className="widget-interactive-area h-full w-full"> <div className="widget-interactive-area h-full w-full">
<StatusSummaryWidget <StatusSummaryWidget element={element} title="상태 요약" icon="📊" bgGradient="from-slate-50 to-blue-50" />
element={element}
title="상태 요약"
icon="📊"
bgGradient="from-slate-50 to-blue-50"
/>
</div> </div>
) : /* element.type === "widget" && element.subtype === "list-summary" ? ( ) : /* element.type === "widget" && element.subtype === "list-summary" ? (
// 커스텀 목록 카드 - 범용 위젯 (다른 분 작업 중 - 임시 주석) // 커스텀 목록 카드 - 범용 위젯 (다른 분 작업 중 - 임시 주석)
@ -560,7 +561,7 @@ export function CanvasElement({
) : */ element.type === "widget" && element.subtype === "delivery-status" ? ( ) : */ element.type === "widget" && element.subtype === "delivery-status" ? (
// 배송/화물 현황 위젯 - 범용 위젯 사용 (구버전 호환) // 배송/화물 현황 위젯 - 범용 위젯 사용 (구버전 호환)
<div className="widget-interactive-area h-full w-full"> <div className="widget-interactive-area h-full w-full">
<StatusSummaryWidget <StatusSummaryWidget
element={element} element={element}
title="배송/화물 현황" title="배송/화물 현황"
icon="📦" icon="📦"
@ -570,23 +571,23 @@ export function CanvasElement({
) : element.type === "widget" && element.subtype === "delivery-status-summary" ? ( ) : element.type === "widget" && element.subtype === "delivery-status-summary" ? (
// 배송 상태 요약 - 범용 위젯 사용 // 배송 상태 요약 - 범용 위젯 사용
<div className="widget-interactive-area h-full w-full"> <div className="widget-interactive-area h-full w-full">
<StatusSummaryWidget <StatusSummaryWidget
element={element} element={element}
title="배송 상태 요약" title="배송 상태 요약"
icon="📊" icon="📊"
bgGradient="from-slate-50 to-blue-50" bgGradient="from-slate-50 to-blue-50"
statusConfig={{ statusConfig={{
"배송중": { label: "배송중", color: "blue" }, : { label: "배송중", color: "blue" },
"완료": { label: "완료", color: "green" }, : { label: "완료", color: "green" },
"지연": { label: "지연", color: "red" }, : { label: "지연", color: "red" },
"픽업 대기": { label: "픽업 대기", color: "yellow" } "픽업 대기": { label: "픽업 대기", color: "yellow" },
}} }}
/> />
</div> </div>
) : element.type === "widget" && element.subtype === "delivery-today-stats" ? ( ) : element.type === "widget" && element.subtype === "delivery-today-stats" ? (
// 오늘 처리 현황 - 범용 위젯 사용 // 오늘 처리 현황 - 범용 위젯 사용
<div className="widget-interactive-area h-full w-full"> <div className="widget-interactive-area h-full w-full">
<StatusSummaryWidget <StatusSummaryWidget
element={element} element={element}
title="오늘 처리 현황" title="오늘 처리 현황"
icon="📈" icon="📈"
@ -596,7 +597,7 @@ export function CanvasElement({
) : element.type === "widget" && element.subtype === "cargo-list" ? ( ) : element.type === "widget" && element.subtype === "cargo-list" ? (
// 화물 목록 - 범용 위젯 사용 // 화물 목록 - 범용 위젯 사용
<div className="widget-interactive-area h-full w-full"> <div className="widget-interactive-area h-full w-full">
<StatusSummaryWidget <StatusSummaryWidget
element={element} element={element}
title="화물 목록" title="화물 목록"
icon="📦" icon="📦"
@ -606,7 +607,7 @@ export function CanvasElement({
) : element.type === "widget" && element.subtype === "customer-issues" ? ( ) : element.type === "widget" && element.subtype === "customer-issues" ? (
// 고객 클레임/이슈 - 범용 위젯 사용 // 고객 클레임/이슈 - 범용 위젯 사용
<div className="widget-interactive-area h-full w-full"> <div className="widget-interactive-area h-full w-full">
<StatusSummaryWidget <StatusSummaryWidget
element={element} element={element}
title="고객 클레임/이슈" title="고객 클레임/이슈"
icon="⚠️" icon="⚠️"

View File

@ -1,9 +1,9 @@
"use client"; "use client";
import React, { forwardRef, useState, useCallback, useEffect } from "react"; import React, { forwardRef, useState, useCallback, useMemo } from "react";
import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types"; import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types";
import { CanvasElement } from "./CanvasElement"; import { CanvasElement } from "./CanvasElement";
import { GRID_CONFIG, snapToGrid } from "./gridUtils"; import { GRID_CONFIG, snapToGrid, calculateGridConfig } from "./gridUtils";
interface DashboardCanvasProps { interface DashboardCanvasProps {
elements: DashboardElement[]; elements: DashboardElement[];
@ -14,6 +14,8 @@ interface DashboardCanvasProps {
onSelectElement: (id: string | null) => void; onSelectElement: (id: string | null) => void;
onConfigureElement?: (element: DashboardElement) => void; onConfigureElement?: (element: DashboardElement) => void;
backgroundColor?: string; backgroundColor?: string;
canvasWidth?: number;
canvasHeight?: number;
} }
/** /**
@ -34,11 +36,17 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
onSelectElement, onSelectElement,
onConfigureElement, onConfigureElement,
backgroundColor = "#f9fafb", backgroundColor = "#f9fafb",
canvasWidth = 1560,
canvasHeight = 768,
}, },
ref, ref,
) => { ) => {
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
// 현재 캔버스 크기에 맞는 그리드 설정 계산
const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]);
const cellSize = gridConfig.CELL_SIZE;
// 드래그 오버 처리 // 드래그 오버 처리
const handleDragOver = useCallback((e: React.DragEvent) => { const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@ -71,20 +79,20 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0); const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0); const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0);
// 그리드에 스냅 (고정 셀 크기 사용) // 그리드에 스냅 (동적 셀 크기 사용)
let snappedX = snapToGrid(rawX, GRID_CONFIG.CELL_SIZE); let snappedX = snapToGrid(rawX, cellSize);
const snappedY = snapToGrid(rawY, GRID_CONFIG.CELL_SIZE); const snappedY = snapToGrid(rawY, cellSize);
// X 좌표가 캔버스 너비를 벗어나지 않도록 제한 // X 좌표가 캔버스 너비를 벗어나지 않도록 제한
const maxX = GRID_CONFIG.CANVAS_WIDTH - GRID_CONFIG.CELL_SIZE * 2; // 최소 2칸 너비 보장 const maxX = canvasWidth - cellSize * 2; // 최소 2칸 너비 보장
snappedX = Math.max(0, Math.min(snappedX, maxX)); snappedX = Math.max(0, Math.min(snappedX, maxX));
onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY); onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY);
} catch (error) { } catch {
// console.error('드롭 데이터 파싱 오류:', error); // 드롭 데이터 파싱 오류 무시
} }
}, },
[ref, onCreateElement], [ref, onCreateElement, canvasWidth, cellSize],
); );
// 캔버스 클릭 시 선택 해제 // 캔버스 클릭 시 선택 해제
@ -97,28 +105,25 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
[onSelectElement], [onSelectElement],
); );
// 고정 그리드 크기 // 동적 그리드 크기 계산
const cellWithGap = GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP; const cellWithGap = cellSize + GRID_CONFIG.GAP;
const gridSize = `${cellWithGap}px ${cellWithGap}px`; const gridSize = `${cellWithGap}px ${cellWithGap}px`;
// 캔버스 높이를 요소들의 최대 y + height 기준으로 계산 (최소 화면 높이 보장) // 12개 컬럼 구분선 위치 계산
const minCanvasHeight = Math.max( const columnLines = Array.from({ length: GRID_CONFIG.COLUMNS + 1 }, (_, i) => i * cellWithGap);
typeof window !== "undefined" ? window.innerHeight : 800,
...elements.map((el) => el.position.y + el.size.height + 100), // 하단 여백 100px
);
return ( return (
<div <div
ref={ref} ref={ref}
className={`relative rounded-lg shadow-inner ${isDragOver ? "bg-blue-50/50" : ""} `} className={`relative w-full rounded-lg shadow-inner ${isDragOver ? "bg-blue-50/50" : ""} `}
style={{ style={{
backgroundColor, backgroundColor,
width: `${GRID_CONFIG.CANVAS_WIDTH}px`, height: `${canvasHeight}px`,
minHeight: `${minCanvasHeight}px`, minHeight: `${canvasHeight}px`,
// 12 컬럼 그리드 배경 // 세밀한 그리드 배경
backgroundImage: ` backgroundImage: `
linear-gradient(rgba(59, 130, 246, 0.15) 1px, transparent 1px), linear-gradient(rgba(59, 130, 246, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(59, 130, 246, 0.15) 1px, transparent 1px) linear-gradient(90deg, rgba(59, 130, 246, 0.08) 1px, transparent 1px)
`, `,
backgroundSize: gridSize, backgroundSize: gridSize,
backgroundPosition: "0 0", backgroundPosition: "0 0",
@ -129,13 +134,33 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
onDrop={handleDrop} onDrop={handleDrop}
onClick={handleCanvasClick} onClick={handleCanvasClick}
> >
{/* 12개 컬럼 메인 구분선 */}
{columnLines.map((x, i) => (
<div
key={`col-${i}`}
className="pointer-events-none absolute top-0 h-full"
style={{
left: `${x}px`,
width: "2px",
zIndex: 1,
}}
/>
))}
{/* 배치된 요소들 렌더링 */} {/* 배치된 요소들 렌더링 */}
{elements.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-gray-400">
<div className="text-center">
<div className="text-sm"> </div>
</div>
</div>
)}
{elements.map((element) => ( {elements.map((element) => (
<CanvasElement <CanvasElement
key={element.id} key={element.id}
element={element} element={element}
isSelected={selectedElement === element.id} isSelected={selectedElement === element.id}
cellSize={GRID_CONFIG.CELL_SIZE} cellSize={cellSize}
canvasWidth={canvasWidth}
onUpdate={onUpdateElement} onUpdate={onUpdateElement}
onRemove={onRemoveElement} onRemove={onRemoveElement}
onSelect={onSelectElement} onSelect={onSelectElement}

View File

@ -3,12 +3,12 @@
import React, { useState, useRef, useCallback } from "react"; import React, { useState, useRef, useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { DashboardCanvas } from "./DashboardCanvas"; import { DashboardCanvas } from "./DashboardCanvas";
import { DashboardSidebar } from "./DashboardSidebar"; import { DashboardTopMenu } from "./DashboardTopMenu";
import { DashboardToolbar } from "./DashboardToolbar";
import { ElementConfigModal } from "./ElementConfigModal"; import { ElementConfigModal } from "./ElementConfigModal";
import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal"; import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal";
import { DashboardElement, ElementType, ElementSubtype } from "./types"; import { DashboardElement, ElementType, ElementSubtype } from "./types";
import { GRID_CONFIG } from "./gridUtils"; import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils";
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
interface DashboardDesignerProps { interface DashboardDesignerProps {
dashboardId?: string; dashboardId?: string;
@ -33,6 +33,82 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState<string>("#f9fafb"); const [canvasBackgroundColor, setCanvasBackgroundColor] = useState<string>("#f9fafb");
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
// 화면 해상도 자동 감지
const [screenResolution] = useState<Resolution>(() => detectScreenResolution());
const [resolution, setResolution] = useState<Resolution>(screenResolution);
// resolution 변경 감지 및 요소 자동 조정
const handleResolutionChange = useCallback(
(newResolution: Resolution) => {
console.log("🎯 해상도 변경 요청:", newResolution);
setResolution((prev) => {
console.log("🎯 이전 해상도:", prev);
// 이전 해상도와 새 해상도의 캔버스 너비 비율 계산
const oldConfig = RESOLUTIONS[prev];
const newConfig = RESOLUTIONS[newResolution];
const widthRatio = newConfig.width / oldConfig.width;
console.log("📐 너비 비율:", widthRatio, `(${oldConfig.width}px → ${newConfig.width}px)`);
// 요소들의 위치와 크기를 비율에 맞춰 조정
if (widthRatio !== 1 && elements.length > 0) {
// 새 해상도의 셀 크기 계산
const newCellSize = calculateCellSize(newConfig.width);
const adjustedElements = elements.map((el) => {
// 비율에 맞춰 조정 (X와 너비만)
const scaledX = el.position.x * widthRatio;
const scaledWidth = el.size.width * widthRatio;
// 그리드에 스냅 (X, Y, 너비, 높이 모두)
const snappedX = snapToGrid(scaledX, newCellSize);
const snappedY = snapToGrid(el.position.y, newCellSize);
const snappedWidth = snapSizeToGrid(scaledWidth, 2, newCellSize);
const snappedHeight = snapSizeToGrid(el.size.height, 2, newCellSize);
return {
...el,
position: {
x: snappedX,
y: snappedY,
},
size: {
width: snappedWidth,
height: snappedHeight,
},
};
});
console.log("✨ 요소 위치/크기 자동 조정 (그리드 스냅 적용):", adjustedElements.length, "개");
setElements(adjustedElements);
}
return newResolution;
});
},
[elements],
);
// 현재 해상도 설정 (안전하게 기본값 제공)
const canvasConfig = RESOLUTIONS[resolution] || RESOLUTIONS.fhd;
// 캔버스 높이 동적 계산 (요소들의 최하단 위치 기준)
const calculateCanvasHeight = useCallback(() => {
if (elements.length === 0) {
return canvasConfig.height; // 기본 높이
}
// 모든 요소의 최하단 y 좌표 계산
const maxBottomY = Math.max(...elements.map((el) => el.position.y + el.size.height));
// 최소 높이는 기본 높이, 요소가 아래로 내려가면 자동으로 늘어남
// 패딩 추가 (100px 여유)
return Math.max(canvasConfig.height, maxBottomY + 100);
}, [elements, canvasConfig.height]);
const dynamicCanvasHeight = calculateCanvasHeight();
// 대시보드 ID가 props로 전달되면 로드 // 대시보드 ID가 props로 전달되면 로드
React.useEffect(() => { React.useEffect(() => {
if (initialDashboardId) { if (initialDashboardId) {
@ -55,6 +131,25 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
setDashboardId(dashboard.id); setDashboardId(dashboard.id);
setDashboardTitle(dashboard.title); setDashboardTitle(dashboard.title);
// 저장된 설정 복원
console.log("🔍 로드된 대시보드:", dashboard);
console.log("📦 저장된 settings:", (dashboard as any).settings);
console.log("🎯 settings 타입:", typeof (dashboard as any).settings);
console.log("🔍 resolution 값:", (dashboard as any).settings?.resolution);
if ((dashboard as any).settings?.resolution) {
const savedResolution = (dashboard as any).settings.resolution as Resolution;
console.log("✅ 저장된 해상도 복원:", savedResolution);
setResolution(savedResolution);
} else {
console.log("⚠️ 저장된 해상도 없음");
}
if ((dashboard as any).settings?.backgroundColor) {
console.log("✅ 저장된 배경색 복원:", (dashboard as any).settings.backgroundColor);
setCanvasBackgroundColor((dashboard as any).settings.backgroundColor);
}
// 요소들 설정 // 요소들 설정
if (dashboard.elements && dashboard.elements.length > 0) { if (dashboard.elements && dashboard.elements.length > 0) {
setElements(dashboard.elements); setElements(dashboard.elements);
@ -81,9 +176,15 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
} }
}; };
// 새로운 요소 생성 (고정 그리드 기반 기본 크기) // 새로운 요소 생성 (동적 그리드 기반 기본 크기)
const createElement = useCallback( const createElement = useCallback(
(type: ElementType, subtype: ElementSubtype, x: number, y: number) => { (type: ElementType, subtype: ElementSubtype, x: number, y: number) => {
// 좌표 유효성 검사
if (isNaN(x) || isNaN(y)) {
console.error("Invalid coordinates:", { x, y });
return;
}
// 기본 크기 설정 // 기본 크기 설정
let defaultCells = { width: 2, height: 2 }; // 기본 위젯 크기 let defaultCells = { width: 2, height: 2 }; // 기본 위젯 크기
@ -93,11 +194,26 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
defaultCells = { width: 2, height: 3 }; // 달력 최소 크기 defaultCells = { width: 2, height: 3 }; // 달력 최소 크기
} }
const cellWithGap = GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP; // 현재 해상도에 맞는 셀 크기 계산
const cellSize = Math.floor((canvasConfig.width + GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS) - GRID_CONFIG.GAP;
const cellWithGap = cellSize + GRID_CONFIG.GAP;
const defaultWidth = defaultCells.width * cellWithGap - GRID_CONFIG.GAP; const defaultWidth = defaultCells.width * cellWithGap - GRID_CONFIG.GAP;
const defaultHeight = defaultCells.height * cellWithGap - GRID_CONFIG.GAP; const defaultHeight = defaultCells.height * cellWithGap - GRID_CONFIG.GAP;
// 크기 유효성 검사
if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) {
console.error("Invalid size calculated:", {
canvasConfig,
cellSize,
cellWithGap,
defaultCells,
defaultWidth,
defaultHeight,
});
return;
}
const newElement: DashboardElement = { const newElement: DashboardElement = {
id: `element-${elementCounter + 1}`, id: `element-${elementCounter + 1}`,
type, type,
@ -112,7 +228,25 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
setElementCounter((prev) => prev + 1); setElementCounter((prev) => prev + 1);
setSelectedElement(newElement.id); setSelectedElement(newElement.id);
}, },
[elementCounter], [elementCounter, canvasConfig.width],
);
// 메뉴에서 요소 추가 시 (캔버스 중앙에 배치)
const addElementFromMenu = useCallback(
(type: ElementType, subtype: ElementSubtype) => {
// 캔버스 중앙 좌표 계산
const centerX = Math.floor(canvasConfig.width / 2);
const centerY = Math.floor(canvasConfig.height / 2);
// 좌표 유효성 확인
if (isNaN(centerX) || isNaN(centerY)) {
console.error("Invalid canvas config:", canvasConfig);
return;
}
createElement(type, subtype, centerX, centerY);
},
[canvasConfig.width, canvasConfig.height, createElement],
); );
// 요소 업데이트 // 요소 업데이트
@ -200,9 +334,24 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
if (dashboardId) { if (dashboardId) {
// 기존 대시보드 업데이트 // 기존 대시보드 업데이트
savedDashboard = await dashboardApi.updateDashboard(dashboardId, { console.log("💾 저장 시작 - 현재 resolution 상태:", resolution);
console.log("💾 저장 시작 - 현재 배경색 상태:", canvasBackgroundColor);
const updateData = {
elements: elementsData, elements: elementsData,
}); settings: {
resolution,
backgroundColor: canvasBackgroundColor,
},
};
console.log("💾 저장할 데이터:", updateData);
console.log("💾 저장할 settings:", updateData.settings);
savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData);
console.log("✅ 저장된 대시보드:", savedDashboard);
console.log("✅ 저장된 settings:", (savedDashboard as any).settings);
alert(`대시보드 "${savedDashboard.title}"이 업데이트되었습니다!`); alert(`대시보드 "${savedDashboard.title}"이 업데이트되었습니다!`);
@ -220,6 +369,10 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
description: description || undefined, description: description || undefined,
isPublic: false, isPublic: false,
elements: elementsData, elements: elementsData,
settings: {
resolution,
backgroundColor: canvasBackgroundColor,
},
}; };
savedDashboard = await dashboardApi.createDashboard(dashboardData); savedDashboard = await dashboardApi.createDashboard(dashboardData);
@ -234,7 +387,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류"; const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}\n\n관리자에게 문의하세요.`); alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}\n\n관리자에게 문의하세요.`);
} }
}, [elements, dashboardId, router]); }, [elements, dashboardId, router, resolution, canvasBackgroundColor]);
// 로딩 중이면 로딩 화면 표시 // 로딩 중이면 로딩 화면 표시
if (isLoading) { if (isLoading) {
@ -250,25 +403,30 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
} }
return ( return (
<div className="flex h-full bg-gray-50"> <div className="flex h-full flex-col bg-gray-50">
{/* 캔버스 영역 */} {/* 상단 메뉴바 */}
<div className="relative flex-1 overflow-auto border-r-2 border-gray-300 bg-gray-100"> <DashboardTopMenu
{/* 편집 중인 대시보드 표시 */} onSaveLayout={saveLayout}
{dashboardTitle && ( onClearCanvas={clearCanvas}
<div className="bg-accent0 absolute top-6 left-6 z-10 rounded-lg px-3 py-1 text-sm font-medium text-white shadow-lg"> onViewDashboard={dashboardId ? () => router.push(`/dashboard/${dashboardId}`) : undefined}
📝 : {dashboardTitle} dashboardTitle={dashboardTitle}
</div> onAddElement={addElementFromMenu}
)} resolution={resolution}
onResolutionChange={handleResolutionChange}
currentScreenResolution={screenResolution}
backgroundColor={canvasBackgroundColor}
onBackgroundColorChange={setCanvasBackgroundColor}
/>
<DashboardToolbar {/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */}
onClearCanvas={clearCanvas} <div className="flex flex-1 items-start justify-center overflow-auto bg-gray-100 p-8">
onSaveLayout={saveLayout} <div
canvasBackgroundColor={canvasBackgroundColor} className="relative shadow-2xl"
onCanvasBackgroundColorChange={setCanvasBackgroundColor} style={{
/> width: `${canvasConfig.width}px`,
minHeight: `${canvasConfig.height}px`,
{/* 캔버스 중앙 정렬 컨테이너 */} }}
<div className="flex justify-center p-4"> >
<DashboardCanvas <DashboardCanvas
ref={canvasRef} ref={canvasRef}
elements={elements} elements={elements}
@ -279,13 +437,12 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
onSelectElement={setSelectedElement} onSelectElement={setSelectedElement}
onConfigureElement={openConfigModal} onConfigureElement={openConfigModal}
backgroundColor={canvasBackgroundColor} backgroundColor={canvasBackgroundColor}
canvasWidth={canvasConfig.width}
canvasHeight={dynamicCanvasHeight}
/> />
</div> </div>
</div> </div>
{/* 사이드바 */}
<DashboardSidebar />
{/* 요소 설정 모달 */} {/* 요소 설정 모달 */}
{configModalElement && ( {configModalElement && (
<> <>

View File

@ -0,0 +1,230 @@
"use client";
import React from "react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Save, Trash2, Eye, Palette } from "lucide-react";
import { ElementType, ElementSubtype } from "./types";
import { ResolutionSelector, Resolution } from "./ResolutionSelector";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface DashboardTopMenuProps {
onSaveLayout: () => void;
onClearCanvas: () => void;
onViewDashboard?: () => void;
dashboardTitle?: string;
onAddElement?: (type: ElementType, subtype: ElementSubtype) => void;
resolution?: Resolution;
onResolutionChange?: (resolution: Resolution) => void;
currentScreenResolution?: Resolution;
backgroundColor?: string;
onBackgroundColorChange?: (color: string) => void;
}
/**
*
* - / ()
* - //
*/
export function DashboardTopMenu({
onSaveLayout,
onClearCanvas,
onViewDashboard,
dashboardTitle,
onAddElement,
resolution = "fhd",
onResolutionChange,
currentScreenResolution,
backgroundColor = "#f9fafb",
onBackgroundColorChange,
}: DashboardTopMenuProps) {
const [chartValue, setChartValue] = React.useState<string>("");
const [widgetValue, setWidgetValue] = React.useState<string>("");
// 차트 선택 시 캔버스 중앙에 추가
const handleChartSelect = (value: string) => {
if (onAddElement) {
onAddElement("chart", value as ElementSubtype);
// 선택 후 즉시 리셋하여 같은 항목을 연속으로 선택 가능하게
setTimeout(() => setChartValue(""), 0);
}
};
// 위젯 선택 시 캔버스 중앙에 추가
const handleWidgetSelect = (value: string) => {
if (onAddElement) {
onAddElement("widget", value as ElementSubtype);
// 선택 후 즉시 리셋하여 같은 항목을 연속으로 선택 가능하게
setTimeout(() => setWidgetValue(""), 0);
}
};
return (
<div className="flex h-16 items-center justify-between border-b bg-white px-6 shadow-sm">
{/* 좌측: 대시보드 제목 */}
<div className="flex items-center gap-4">
{dashboardTitle && (
<div className="flex items-center gap-2">
<span className="text-lg font-semibold text-gray-900">{dashboardTitle}</span>
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700"> </span>
</div>
)}
</div>
{/* 중앙: 해상도 선택 & 요소 추가 */}
<div className="flex items-center gap-3">
{/* 해상도 선택 */}
{onResolutionChange && (
<ResolutionSelector
value={resolution}
onChange={onResolutionChange}
currentScreenResolution={currentScreenResolution}
/>
)}
<div className="h-6 w-px bg-gray-300" />
{/* 배경색 선택 */}
{onBackgroundColorChange && (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Palette className="h-4 w-4" />
<div className="h-4 w-4 rounded border border-gray-300" style={{ backgroundColor }} />
</Button>
</PopoverTrigger>
<PopoverContent className="z-[99999] w-64">
<div className="space-y-3">
<div>
<label className="text-sm font-medium"> </label>
</div>
<div className="flex gap-2">
<Input
type="color"
value={backgroundColor}
onChange={(e) => onBackgroundColorChange(e.target.value)}
className="h-10 w-20 cursor-pointer"
/>
<Input
type="text"
value={backgroundColor}
onChange={(e) => onBackgroundColorChange(e.target.value)}
placeholder="#f9fafb"
className="flex-1"
/>
</div>
<div className="grid grid-cols-6 gap-2">
{[
"#ffffff",
"#f9fafb",
"#f3f4f6",
"#e5e7eb",
"#1f2937",
"#111827",
"#fef3c7",
"#fde68a",
"#dbeafe",
"#bfdbfe",
"#fecaca",
"#fca5a5",
].map((color) => (
<button
key={color}
className="h-8 w-8 rounded border-2 transition-transform hover:scale-110"
style={{
backgroundColor: color,
borderColor: backgroundColor === color ? "#3b82f6" : "#d1d5db",
}}
onClick={() => onBackgroundColorChange(color)}
/>
))}
</div>
</div>
</PopoverContent>
</Popover>
)}
<div className="h-6 w-px bg-gray-300" />
{/* 차트 선택 */}
<Select value={chartValue} onValueChange={handleChartSelect}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="차트 추가" />
</SelectTrigger>
<SelectContent className="z-[99999]">
<SelectGroup>
<SelectLabel></SelectLabel>
<SelectItem value="bar"> </SelectItem>
<SelectItem value="horizontal-bar"> </SelectItem>
<SelectItem value="stacked-bar"> </SelectItem>
<SelectItem value="line"> </SelectItem>
<SelectItem value="area"> </SelectItem>
<SelectItem value="pie"> </SelectItem>
<SelectItem value="donut"> </SelectItem>
<SelectItem value="combo"> </SelectItem>
</SelectGroup>
</SelectContent>
</Select>
{/* 위젯 선택 */}
<Select value={widgetValue} onValueChange={handleWidgetSelect}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="위젯 추가" />
</SelectTrigger>
<SelectContent className="z-[99999]">
<SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="list"> </SelectItem>
<SelectItem value="map"></SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="weather"></SelectItem>
<SelectItem value="exchange"></SelectItem>
<SelectItem value="calculator"></SelectItem>
<SelectItem value="calendar"></SelectItem>
<SelectItem value="clock"></SelectItem>
<SelectItem value="todo"> </SelectItem>
<SelectItem value="booking-alert"> </SelectItem>
<SelectItem value="maintenance"> </SelectItem>
<SelectItem value="document"></SelectItem>
<SelectItem value="risk-alert"> </SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="vehicle-status"> </SelectItem>
<SelectItem value="vehicle-list"> </SelectItem>
<SelectItem value="vehicle-map"> </SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
{/* 우측: 액션 버튼 */}
<div className="flex items-center gap-2">
{onViewDashboard && (
<Button variant="outline" size="sm" onClick={onViewDashboard} className="gap-2">
<Eye className="h-4 w-4" />
</Button>
)}
<Button variant="outline" size="sm" onClick={onClearCanvas} className="gap-2 text-red-600 hover:text-red-700">
<Trash2 className="h-4 w-4" />
</Button>
<Button size="sm" onClick={onSaveLayout} className="gap-2">
<Save className="h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,122 @@
"use client";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Monitor } from "lucide-react";
export type Resolution = "hd" | "fhd" | "qhd" | "uhd";
export interface ResolutionConfig {
width: number;
height: number;
label: string;
}
export const RESOLUTIONS: Record<Resolution, ResolutionConfig> = {
hd: {
width: 1280 - 360,
height: 720 - 312,
label: "HD (1280x720)",
},
fhd: {
width: 1920 - 360,
height: 1080 - 312,
label: "Full HD (1920x1080)",
},
qhd: {
width: 2560 - 360,
height: 1440 - 312,
label: "QHD (2560x1440)",
},
uhd: {
width: 3840 - 360,
height: 2160 - 312,
label: "4K UHD (3840x2160)",
},
};
interface ResolutionSelectorProps {
value: Resolution;
onChange: (resolution: Resolution) => void;
currentScreenResolution?: Resolution;
}
/**
*
*/
export function detectScreenResolution(): Resolution {
if (typeof window === "undefined") return "fhd";
const width = window.screen.width;
const height = window.screen.height;
// 화면 해상도에 따라 적절한 캔버스 해상도 반환
if (width >= 3840 || height >= 2160) return "uhd";
if (width >= 2560 || height >= 1440) return "qhd";
if (width >= 1920 || height >= 1080) return "fhd";
return "hd";
}
/**
*
* - HD, Full HD, QHD, 4K UHD
* - 12 ,
* -
*/
export function ResolutionSelector({ value, onChange, currentScreenResolution }: ResolutionSelectorProps) {
const currentConfig = RESOLUTIONS[value];
const screenConfig = currentScreenResolution ? RESOLUTIONS[currentScreenResolution] : null;
// 현재 선택된 해상도가 화면보다 큰지 확인
const isTooLarge =
screenConfig &&
(currentConfig.width > screenConfig.width + 360 || currentConfig.height > screenConfig.height + 312);
return (
<div className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-gray-500" />
<Select value={value} onValueChange={(v) => onChange(v as Resolution)}>
<SelectTrigger className={`w-[180px] ${isTooLarge ? "border-orange-500" : ""}`}>
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[99999]">
<SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="hd">
<div className="flex items-center gap-2">
<span>HD</span>
<span className="text-xs text-gray-500">1280x720</span>
</div>
</SelectItem>
<SelectItem value="fhd">
<div className="flex items-center gap-2">
<span>Full HD</span>
<span className="text-xs text-gray-500">1920x1080</span>
</div>
</SelectItem>
<SelectItem value="qhd">
<div className="flex items-center gap-2">
<span>QHD</span>
<span className="text-xs text-gray-500">2560x1440</span>
</div>
</SelectItem>
<SelectItem value="uhd">
<div className="flex items-center gap-2">
<span>4K UHD</span>
<span className="text-xs text-gray-500">3840x2160</span>
</div>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
{isTooLarge && <span className="text-xs text-orange-600"> </span>}
</div>
);
}

View File

@ -5,18 +5,36 @@
* - * -
*/ */
// 그리드 설정 (고정 크기) // 기본 그리드 설정 (FHD 기준)
export const GRID_CONFIG = { export const GRID_CONFIG = {
COLUMNS: 12, COLUMNS: 12, // 모든 해상도에서 12칸 고정
CELL_SIZE: 132, // 고정 셀 크기 GAP: 8, // 셀 간격 고정
GAP: 8, // 셀 간격
SNAP_THRESHOLD: 15, // 스냅 임계값 (px) SNAP_THRESHOLD: 15, // 스냅 임계값 (px)
ELEMENT_PADDING: 4, // 요소 주위 여백 (px) ELEMENT_PADDING: 4, // 요소 주위 여백 (px)
CANVAS_WIDTH: 1682, // 고정 캔버스 너비 (실제 측정값) // CELL_SIZE와 CANVAS_WIDTH는 해상도에 따라 동적 계산
// 계산식: (132 + 8) × 12 - 8 = 1672px (그리드)
// 추가 여백 10px 포함 = 1682px
} as const; } as const;
/**
*
* : (CELL_SIZE + GAP) * 12 - GAP = canvasWidth
* CELL_SIZE = (canvasWidth + GAP) / 12 - GAP
*/
export function calculateCellSize(canvasWidth: number): number {
return Math.floor((canvasWidth + GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS) - GRID_CONFIG.GAP;
}
/**
*
*/
export function calculateGridConfig(canvasWidth: number) {
const cellSize = calculateCellSize(canvasWidth);
return {
...GRID_CONFIG,
CELL_SIZE: cellSize,
CANVAS_WIDTH: canvasWidth,
};
}
/** /**
* (gap ) * (gap )
*/ */

View File

@ -78,6 +78,10 @@ export interface Dashboard {
elementsCount?: number; elementsCount?: number;
creatorName?: string; creatorName?: string;
elements?: DashboardElement[]; elements?: DashboardElement[];
settings?: {
resolution?: string;
backgroundColor?: string;
};
} }
export interface CreateDashboardRequest { export interface CreateDashboardRequest {
@ -87,6 +91,10 @@ export interface CreateDashboardRequest {
elements: DashboardElement[]; elements: DashboardElement[];
tags?: string[]; tags?: string[];
category?: string; category?: string;
settings?: {
resolution?: string;
backgroundColor?: string;
};
} }
export interface DashboardListQuery { export interface DashboardListQuery {

View File

@ -20,17 +20,10 @@ echo ""
echo "[1/6] Git 최신 코드 가져오기..." echo "[1/6] Git 최신 코드 가져오기..."
git pull origin main git pull origin main
# 호스트 디렉토리 준비 # Docker 볼륨 사용으로 호스트 디렉토리 준비 불필요
echo "" echo ""
echo "[2/6] 호스트 디렉토리 준비..." echo "[2/6] Docker 볼륨 확인..."
mkdir -p /home/vexplor/backend_data/data/mail-sent echo "Docker named volumes 사용 (권한 문제 없음)"
mkdir -p /home/vexplor/backend_data/uploads/mail-attachments
mkdir -p /home/vexplor/backend_data/uploads/mail-templates
mkdir -p /home/vexplor/backend_data/uploads/mail-accounts
mkdir -p /home/vexplor/frontend_data
chmod -R 755 /home/vexplor/backend_data
chmod -R 755 /home/vexplor/frontend_data
echo "디렉토리 생성 완료 (mail-sent, mail-attachments, mail-templates, mail-accounts, frontend)"
# 기존 컨테이너 중지 및 제거 # 기존 컨테이너 중지 및 제거
echo "" echo ""