Compare commits
16 Commits
f183b4a727
...
d819a7c77e
| Author | SHA1 | Date |
|---|---|---|
|
|
d819a7c77e | |
|
|
26d27ac881 | |
|
|
c5499d2e18 | |
|
|
a8c1b4b5e5 | |
|
|
8e0ef82de7 | |
|
|
3bda194bf2 | |
|
|
a7123216f2 | |
|
|
9168844fab | |
|
|
02f67c2372 | |
|
|
9349867476 | |
|
|
8f676c0a6d | |
|
|
9e1a7c53e1 | |
|
|
337cc448d0 | |
|
|
ed9da3962a | |
|
|
3afcd3d9fb | |
|
|
18e2280623 |
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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="⚠️"
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 포함)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue