위젯 헤더 표시/숨김 및 커스텀 제목 기능 복구 - f183b4a 커밋에서 선택적으로 가져옴

This commit is contained in:
leeheejin 2025-10-16 14:06:40 +09:00
parent d819a7c77e
commit 6de288eba5
4 changed files with 261 additions and 468 deletions

View File

@ -1,25 +1,23 @@
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( static async createDashboard(data: CreateDashboardRequest, userId: string): Promise<Dashboard> {
data: CreateDashboardRequest,
userId: string
): Promise<Dashboard> {
const dashboardId = uuidv4(); const dashboardId = uuidv4();
const now = new Date(); const now = new Date();
@ -27,27 +25,23 @@ export class DashboardService {
// 트랜잭션으로 대시보드와 요소들을 함께 생성 // 트랜잭션으로 대시보드와 요소들을 함께 생성
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, settings created_at, updated_at, tags, category, view_count
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`, `, [
[ dashboardId,
dashboardId, data.title,
data.title, data.description || null,
data.description || null, data.isPublic || false,
data.isPublic || false, userId,
userId, now,
now, now,
now, JSON.stringify(data.tags || []),
JSON.stringify(data.tags || []), data.category || null,
data.category || null, 0
0, ]);
JSON.stringify(data.settings || {}),
]
);
// 2. 대시보드 요소들 저장 // 2. 대시보드 요소들 저장
if (data.elements && data.elements.length > 0) { if (data.elements && data.elements.length > 0) {
@ -55,33 +49,32 @@ export class DashboardService {
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, content, data_source_config, chart_config, title, custom_title, show_header, 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) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
`, `, [
[ elementId,
elementId, dashboardId,
dashboardId, element.type,
element.type, element.subtype,
element.subtype, element.position.x,
element.position.x, element.position.y,
element.position.y, element.size.width,
element.size.width, element.size.height,
element.size.height, element.title,
element.title, element.customTitle || null,
element.content || null, element.showHeader !== false,
JSON.stringify(element.dataSource || {}), element.content || null,
JSON.stringify(element.chartConfig || {}), JSON.stringify(element.dataSource || {}),
i, JSON.stringify(element.chartConfig || {}),
now, i,
now, now,
] now
); ]);
} }
} }
@ -92,7 +85,7 @@ export class DashboardService {
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,
@ -106,13 +99,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,
@ -126,11 +119,12 @@ 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;
} }
} }
@ -145,33 +139,29 @@ export class DashboardService {
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( whereConditions.push(`(d.created_by = $${paramIndex} OR d.is_public = true)`);
`(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( whereConditions.push(`(d.title ILIKE $${paramIndex} OR d.description ILIKE $${paramIndex + 1})`);
`(d.title ILIKE $${paramIndex} OR d.description ILIKE $${paramIndex + 1})`
);
params.push(`%${search}%`, `%${search}%`); params.push(`%${search}%`, `%${search}%`);
paramIndex += 2; paramIndex += 2;
} }
@ -184,7 +174,7 @@ export class DashboardService {
} }
// 공개/비공개 필터 // 공개/비공개 필터
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++;
@ -197,7 +187,7 @@ export class DashboardService {
paramIndex++; paramIndex++;
} }
const whereClause = whereConditions.join(" AND "); const whereClause = whereConditions.join(' AND ');
// 대시보드 목록 조회 (users 테이블 조인 제거) // 대시보드 목록 조회 (users 테이블 조인 제거)
const dashboardQuery = ` const dashboardQuery = `
@ -224,11 +214,10 @@ export class DashboardService {
LIMIT $${paramIndex} OFFSET $${paramIndex + 1} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`; `;
const dashboardResult = await PostgreSQLService.query(dashboardQuery, [ const dashboardResult = await PostgreSQLService.query(
...params, dashboardQuery,
limit, [...params, limit, offset]
offset, );
]);
// 전체 개수 조회 // 전체 개수 조회
const countQuery = ` const countQuery = `
@ -238,7 +227,7 @@ export class DashboardService {
`; `;
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) => ({
@ -250,20 +239,20 @@ 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;
} }
} }
@ -271,10 +260,7 @@ export class DashboardService {
/** /**
* *
*/ */
static async getDashboardById( static async getDashboardById(dashboardId: string, userId?: string): Promise<Dashboard | null> {
dashboardId: string,
userId?: string
): Promise<Dashboard | null> {
try { try {
// 1. 대시보드 기본 정보 조회 (권한 체크 포함) // 1. 대시보드 기본 정보 조회 (권한 체크 포함)
let dashboardQuery: string; let dashboardQuery: string;
@ -298,10 +284,7 @@ export class DashboardService {
dashboardParams = [dashboardId]; dashboardParams = [dashboardId];
} }
const dashboardResult = await PostgreSQLService.query( const dashboardResult = await PostgreSQLService.query(dashboardQuery, dashboardParams);
dashboardQuery,
dashboardParams
);
if (dashboardResult.rows.length === 0) { if (dashboardResult.rows.length === 0) {
return null; return null;
@ -316,30 +299,36 @@ export class DashboardService {
ORDER BY display_order ASC ORDER BY display_order ASC
`; `;
const elementsResult = await PostgreSQLService.query(elementsQuery, [ const elementsResult = await PostgreSQLService.query(elementsQuery, [dashboardId]);
dashboardId,
]);
// 3. 요소 데이터 변환 // 3. 요소 데이터 변환
const elements: DashboardElement[] = elementsResult.rows.map( console.log('📊 대시보드 요소 개수:', elementsResult.rows.length);
(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,
@ -350,14 +339,13 @@ 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'),
settings: dashboard.settings || undefined, elements
elements,
}; };
} catch (error) { } catch (error) {
console.error("Dashboard get error:", error); console.error('Dashboard get error:', error);
throw error; throw error;
} }
} }
@ -373,16 +361,13 @@ export class DashboardService {
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();
@ -417,11 +402,6 @@ 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);
@ -429,11 +409,10 @@ export class DashboardService {
updateParams.push(dashboardId); updateParams.push(dashboardId);
if (updateFields.length > 1) { if (updateFields.length > 1) { // updated_at 외에 다른 필드가 있는 경우
// updated_at 외에 다른 필드가 있는 경우
const updateQuery = ` const updateQuery = `
UPDATE dashboards UPDATE dashboards
SET ${updateFields.join(", ")} SET ${updateFields.join(', ')}
WHERE id = $${paramIndex} WHERE id = $${paramIndex}
`; `;
@ -443,45 +422,41 @@ export class DashboardService {
// 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, content, data_source_config, chart_config, title, custom_title, show_header, 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) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
`, `, [
[ elementId,
elementId, dashboardId,
dashboardId, element.type,
element.type, element.subtype,
element.subtype, element.position.x,
element.position.x, element.position.y,
element.position.y, element.size.width,
element.size.width, element.size.height,
element.size.height, element.title,
element.title, element.customTitle || null,
element.content || null, element.showHeader !== false,
JSON.stringify(element.dataSource || {}), element.content || null,
JSON.stringify(element.chartConfig || {}), JSON.stringify(element.dataSource || {}),
i, JSON.stringify(element.chartConfig || {}),
now, i,
now, now,
] now
); ]);
} }
} }
@ -490,8 +465,9 @@ export class DashboardService {
// 업데이트된 대시보드 반환 // 업데이트된 대시보드 반환
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;
} }
} }
@ -499,25 +475,19 @@ export class DashboardService {
/** /**
* ( ) * ( )
*/ */
static async deleteDashboard( static async deleteDashboard(dashboardId: string, userId: string): Promise<boolean> {
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;
} }
} }
@ -527,16 +497,13 @@ export class DashboardService {
*/ */
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);
// 조회수 증가 실패는 치명적이지 않으므로 에러를 던지지 않음 // 조회수 증가 실패는 치명적이지 않으므로 에러를 던지지 않음
} }
} }
@ -547,11 +514,10 @@ export class DashboardService {
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'
@ -560,9 +526,7 @@ 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;
@ -571,14 +535,13 @@ export class DashboardService {
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 = const userLevel = permissionLevels[userPermission as keyof typeof permissionLevels] || 0;
permissionLevels[userPermission as keyof typeof permissionLevels] || 0;
const requiredLevel = permissionLevels[requiredPermission]; const requiredLevel = permissionLevels[requiredPermission];
return userLevel >= requiredLevel; return userLevel >= requiredLevel;
} catch (error) { } catch (error) {
console.error("Permission check error:", error); console.error('Permission check error:', error);
return false; return false;
} }
} }

View File

@ -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,10 +50,6 @@ export interface Dashboard {
tags?: string[]; tags?: string[];
category?: string; category?: string;
viewCount: number; viewCount: number;
settings?: {
resolution?: string;
backgroundColor?: string;
};
elements: DashboardElement[]; elements: DashboardElement[];
} }
@ -64,10 +60,6 @@ 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 {
@ -77,10 +69,6 @@ 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 {
@ -97,7 +85,7 @@ export interface DashboardShare {
dashboardId: string; dashboardId: string;
sharedWithUser?: string; sharedWithUser?: string;
sharedWithRole?: string; sharedWithRole?: string;
permissionLevel: "view" | "edit" | "admin"; permissionLevel: 'view' | 'edit' | 'admin';
createdBy: string; createdBy: string;
createdAt: string; createdAt: string;
expiresAt?: string; expiresAt?: string;

View File

@ -110,7 +110,6 @@ 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;
@ -127,7 +126,6 @@ export function CanvasElement({
element, element,
isSelected, isSelected,
cellSize, cellSize,
canvasWidth = 1560,
onUpdate, onUpdate,
onRemove, onRemove,
onSelect, onSelect,
@ -166,11 +164,7 @@ 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,
@ -180,7 +174,7 @@ export function CanvasElement({
}); });
e.preventDefault(); e.preventDefault();
}, },
[element.id, element.position.x, element.position.y, onSelect, isSelected], [element.id, element.position.x, element.position.y, onSelect],
); );
// 리사이즈 핸들 마우스다운 // 리사이즈 핸들 마우스다운
@ -213,7 +207,7 @@ export function CanvasElement({
const rawY = Math.max(0, dragStart.elementY + deltaY); const rawY = Math.max(0, dragStart.elementY + deltaY);
// X 좌표가 캔버스 너비를 벗어나지 않도록 제한 // X 좌표가 캔버스 너비를 벗어나지 않도록 제한
const maxX = canvasWidth - element.size.width; const maxX = GRID_CONFIG.CANVAS_WIDTH - element.size.width;
rawX = Math.min(rawX, maxX); rawX = Math.min(rawX, maxX);
setTempPosition({ x: rawX, y: rawY }); setTempPosition({ x: rawX, y: rawY });
@ -229,8 +223,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 = cellSize * minWidthCells; const minWidth = GRID_CONFIG.CELL_SIZE * minWidthCells;
const minHeight = cellSize * minHeightCells; const minHeight = GRID_CONFIG.CELL_SIZE * minHeightCells;
switch (resizeStart.handle) { switch (resizeStart.handle) {
case "se": // 오른쪽 아래 case "se": // 오른쪽 아래
@ -256,7 +250,7 @@ export function CanvasElement({
} }
// 가로 너비가 캔버스를 벗어나지 않도록 제한 // 가로 너비가 캔버스를 벗어나지 않도록 제한
const maxWidth = canvasWidth - newX; const maxWidth = GRID_CONFIG.CANVAS_WIDTH - newX;
newWidth = Math.min(newWidth, maxWidth); newWidth = Math.min(newWidth, maxWidth);
// 임시 크기/위치 저장 (스냅 안 됨) // 임시 크기/위치 저장 (스냅 안 됨)
@ -264,7 +258,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, canvasWidth], [isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype],
); );
// 마우스 업 처리 (그리드 스냅 적용) // 마우스 업 처리 (그리드 스냅 적용)
@ -275,7 +269,7 @@ export function CanvasElement({
const snappedY = snapToGrid(tempPosition.y, cellSize); const snappedY = snapToGrid(tempPosition.y, cellSize);
// X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한 // X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한
const maxX = canvasWidth - element.size.width; const maxX = GRID_CONFIG.CANVAS_WIDTH - element.size.width;
snappedX = Math.min(snappedX, maxX); snappedX = Math.min(snappedX, maxX);
onUpdate(element.id, { onUpdate(element.id, {
@ -293,7 +287,7 @@ export function CanvasElement({
const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize); const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize);
// 가로 너비가 캔버스를 벗어나지 않도록 최종 제한 // 가로 너비가 캔버스를 벗어나지 않도록 최종 제한
const maxWidth = canvasWidth - snappedX; const maxWidth = GRID_CONFIG.CANVAS_WIDTH - snappedX;
snappedWidth = Math.min(snappedWidth, maxWidth); snappedWidth = Math.min(snappedWidth, maxWidth);
onUpdate(element.id, { onUpdate(element.id, {
@ -307,7 +301,7 @@ export function CanvasElement({
setIsDragging(false); setIsDragging(false);
setIsResizing(false); setIsResizing(false);
}, [isDragging, isResizing, tempPosition, tempSize, element.id, element.size.width, onUpdate, cellSize, canvasWidth]); }, [isDragging, isResizing, tempPosition, tempSize, element.id, element.size.width, onUpdate, cellSize]);
// 전역 마우스 이벤트 등록 // 전역 마우스 이벤트 등록
React.useEffect(() => { React.useEffect(() => {
@ -551,7 +545,12 @@ 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 element={element} title="상태 요약" icon="📊" bgGradient="from-slate-50 to-blue-50" /> <StatusSummaryWidget
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" ? (
// 커스텀 목록 카드 - 범용 위젯 (다른 분 작업 중 - 임시 주석) // 커스텀 목록 카드 - 범용 위젯 (다른 분 작업 중 - 임시 주석)
@ -577,10 +576,10 @@ export function CanvasElement({
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>

View File

@ -3,12 +3,12 @@
import React, { useState, useRef, useCallback } from "react"; import React, { useState, useRef, useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { DashboardCanvas } from "./DashboardCanvas"; import { DashboardCanvas } from "./DashboardCanvas";
import { DashboardTopMenu } from "./DashboardTopMenu"; import { DashboardSidebar } from "./DashboardSidebar";
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, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils"; import { GRID_CONFIG } from "./gridUtils";
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
interface DashboardDesignerProps { interface DashboardDesignerProps {
dashboardId?: string; dashboardId?: string;
@ -33,82 +33,6 @@ 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) {
@ -131,25 +55,6 @@ 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);
@ -176,15 +81,9 @@ 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 }; // 기본 위젯 크기
@ -194,26 +93,11 @@ 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,
@ -228,25 +112,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
setElementCounter((prev) => prev + 1); setElementCounter((prev) => prev + 1);
setSelectedElement(newElement.id); setSelectedElement(newElement.id);
}, },
[elementCounter, canvasConfig.width], [elementCounter],
);
// 메뉴에서 요소 추가 시 (캔버스 중앙에 배치)
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],
); );
// 요소 업데이트 // 요소 업데이트
@ -334,24 +200,9 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
if (dashboardId) { if (dashboardId) {
// 기존 대시보드 업데이트 // 기존 대시보드 업데이트
console.log("💾 저장 시작 - 현재 resolution 상태:", resolution); savedDashboard = await dashboardApi.updateDashboard(dashboardId, {
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}"이 업데이트되었습니다!`);
@ -369,10 +220,6 @@ 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);
@ -387,7 +234,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, resolution, canvasBackgroundColor]); }, [elements, dashboardId, router]);
// 로딩 중이면 로딩 화면 표시 // 로딩 중이면 로딩 화면 표시
if (isLoading) { if (isLoading) {
@ -403,30 +250,25 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
} }
return ( return (
<div className="flex h-full flex-col bg-gray-50"> <div className="flex h-full bg-gray-50">
{/* 상단 메뉴바 */} {/* 캔버스 영역 */}
<DashboardTopMenu <div className="relative flex-1 overflow-auto border-r-2 border-gray-300 bg-gray-100">
onSaveLayout={saveLayout} {/* 편집 중인 대시보드 표시 */}
onClearCanvas={clearCanvas} {dashboardTitle && (
onViewDashboard={dashboardId ? () => router.push(`/dashboard/${dashboardId}`) : undefined} <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">
dashboardTitle={dashboardTitle} 📝 : {dashboardTitle}
onAddElement={addElementFromMenu} </div>
resolution={resolution} )}
onResolutionChange={handleResolutionChange}
currentScreenResolution={screenResolution}
backgroundColor={canvasBackgroundColor}
onBackgroundColorChange={setCanvasBackgroundColor}
/>
{/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */} <DashboardToolbar
<div className="flex flex-1 items-start justify-center overflow-auto bg-gray-100 p-8"> onClearCanvas={clearCanvas}
<div onSaveLayout={saveLayout}
className="relative shadow-2xl" canvasBackgroundColor={canvasBackgroundColor}
style={{ onCanvasBackgroundColorChange={setCanvasBackgroundColor}
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}
@ -437,12 +279,13 @@ 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 && (
<> <>