Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
commit
025fe04192
|
|
@ -1,554 +0,0 @@
|
|||
import { query } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 권한 그룹 인터페이스
|
||||
*/
|
||||
export interface RoleGroup {
|
||||
objid: number;
|
||||
authName: string;
|
||||
authCode: string;
|
||||
companyCode: string;
|
||||
status: string;
|
||||
writer: string;
|
||||
regdate: Date;
|
||||
memberCount?: number;
|
||||
menuCount?: number;
|
||||
memberNames?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 그룹 멤버 인터페이스
|
||||
*/
|
||||
export interface RoleMember {
|
||||
objid: number;
|
||||
masterObjid: number;
|
||||
userId: string;
|
||||
userName?: string;
|
||||
deptName?: string;
|
||||
positionName?: string;
|
||||
writer: string;
|
||||
regdate: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 권한 인터페이스
|
||||
*/
|
||||
export interface MenuPermission {
|
||||
objid: number;
|
||||
menuObjid: number;
|
||||
authObjid: number;
|
||||
menuName?: string;
|
||||
createYn: string;
|
||||
readYn: string;
|
||||
updateYn: string;
|
||||
deleteYn: string;
|
||||
writer: string;
|
||||
regdate: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 그룹 서비스
|
||||
*/
|
||||
export class RoleService {
|
||||
/**
|
||||
* 회사별 권한 그룹 목록 조회
|
||||
* @param companyCode - 회사 코드 (undefined 시 전체 조회)
|
||||
* @param search - 검색어
|
||||
*/
|
||||
static async getRoleGroups(
|
||||
companyCode?: string,
|
||||
search?: string
|
||||
): Promise<RoleGroup[]> {
|
||||
try {
|
||||
let sql = `
|
||||
SELECT
|
||||
objid,
|
||||
auth_name AS "authName",
|
||||
auth_code AS "authCode",
|
||||
company_code AS "companyCode",
|
||||
status,
|
||||
writer,
|
||||
regdate,
|
||||
(SELECT COUNT(*) FROM authority_sub_user asu WHERE asu.master_objid = am.objid) AS "memberCount",
|
||||
(SELECT COUNT(*) FROM rel_menu_auth rma WHERE rma.auth_objid = am.objid) AS "menuCount",
|
||||
(SELECT STRING_AGG(ui.user_name, ', ' ORDER BY ui.user_name)
|
||||
FROM authority_sub_user asu
|
||||
JOIN user_info ui ON asu.user_id = ui.user_id
|
||||
WHERE asu.master_objid = am.objid) AS "memberNames"
|
||||
FROM authority_master am
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 회사 코드 필터 (companyCode가 undefined면 전체 조회)
|
||||
if (companyCode) {
|
||||
sql += ` AND company_code = $${paramIndex}`;
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 검색어 필터
|
||||
if (search && search.trim()) {
|
||||
sql += ` AND (auth_name ILIKE $${paramIndex} OR auth_code ILIKE $${paramIndex})`;
|
||||
params.push(`%${search.trim()}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
sql += ` ORDER BY regdate DESC`;
|
||||
|
||||
logger.info("권한 그룹 조회 SQL", { sql, params });
|
||||
const result = await query<RoleGroup>(sql, params);
|
||||
logger.info("권한 그룹 조회 결과", { count: result.length });
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error("권한 그룹 목록 조회 실패", { error, companyCode, search });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 그룹 상세 조회
|
||||
*/
|
||||
static async getRoleGroupById(objid: number): Promise<RoleGroup | null> {
|
||||
try {
|
||||
const sql = `
|
||||
SELECT
|
||||
objid,
|
||||
auth_name AS "authName",
|
||||
auth_code AS "authCode",
|
||||
company_code AS "companyCode",
|
||||
status,
|
||||
writer,
|
||||
regdate
|
||||
FROM authority_master
|
||||
WHERE objid = $1
|
||||
`;
|
||||
|
||||
const result = await query<RoleGroup>(sql, [objid]);
|
||||
return result.length > 0 ? result[0] : null;
|
||||
} catch (error) {
|
||||
logger.error("권한 그룹 상세 조회 실패", { error, objid });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 그룹 생성
|
||||
*/
|
||||
static async createRoleGroup(data: {
|
||||
authName: string;
|
||||
authCode: string;
|
||||
companyCode: string;
|
||||
writer: string;
|
||||
}): Promise<RoleGroup> {
|
||||
try {
|
||||
const sql = `
|
||||
INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate)
|
||||
VALUES (nextval('seq_authority_master'), $1, $2, $3, 'active', $4, NOW())
|
||||
RETURNING objid, auth_name AS "authName", auth_code AS "authCode",
|
||||
company_code AS "companyCode", status, writer, regdate
|
||||
`;
|
||||
|
||||
const result = await query<RoleGroup>(sql, [
|
||||
data.authName,
|
||||
data.authCode,
|
||||
data.companyCode,
|
||||
data.writer,
|
||||
]);
|
||||
|
||||
logger.info("권한 그룹 생성 성공", {
|
||||
objid: result[0].objid,
|
||||
authName: data.authName,
|
||||
});
|
||||
return result[0];
|
||||
} catch (error) {
|
||||
logger.error("권한 그룹 생성 실패", { error, data });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 그룹 수정
|
||||
*/
|
||||
static async updateRoleGroup(
|
||||
objid: number,
|
||||
data: {
|
||||
authName?: string;
|
||||
authCode?: string;
|
||||
status?: string;
|
||||
}
|
||||
): Promise<RoleGroup> {
|
||||
try {
|
||||
const updates: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.authName !== undefined) {
|
||||
updates.push(`auth_name = $${paramIndex}`);
|
||||
params.push(data.authName);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.authCode !== undefined) {
|
||||
updates.push(`auth_code = $${paramIndex}`);
|
||||
params.push(data.authCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.status !== undefined) {
|
||||
updates.push(`status = $${paramIndex}`);
|
||||
params.push(data.status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
throw new Error("수정할 데이터가 없습니다");
|
||||
}
|
||||
|
||||
params.push(objid);
|
||||
|
||||
const sql = `
|
||||
UPDATE authority_master
|
||||
SET ${updates.join(", ")}
|
||||
WHERE objid = $${paramIndex}
|
||||
RETURNING objid, auth_name AS "authName", auth_code AS "authCode",
|
||||
company_code AS "companyCode", status, writer, regdate
|
||||
`;
|
||||
|
||||
const result = await query<RoleGroup>(sql, params);
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error("권한 그룹을 찾을 수 없습니다");
|
||||
}
|
||||
|
||||
logger.info("권한 그룹 수정 성공", { objid, updates });
|
||||
return result[0];
|
||||
} catch (error) {
|
||||
logger.error("권한 그룹 수정 실패", { error, objid, data });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 그룹 삭제
|
||||
*/
|
||||
static async deleteRoleGroup(objid: number): Promise<void> {
|
||||
try {
|
||||
// CASCADE로 연결된 데이터도 함께 삭제됨 (authority_sub_user, rel_menu_auth)
|
||||
await query("DELETE FROM authority_master WHERE objid = $1", [objid]);
|
||||
logger.info("권한 그룹 삭제 성공", { objid });
|
||||
} catch (error) {
|
||||
logger.error("권한 그룹 삭제 실패", { error, objid });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 그룹 멤버 목록 조회
|
||||
*/
|
||||
static async getRoleMembers(masterObjid: number): Promise<RoleMember[]> {
|
||||
try {
|
||||
const sql = `
|
||||
SELECT
|
||||
asu.objid,
|
||||
asu.master_objid AS "masterObjid",
|
||||
asu.user_id AS "userId",
|
||||
ui.user_name AS "userName",
|
||||
ui.dept_name AS "deptName",
|
||||
ui.position_name AS "positionName",
|
||||
asu.writer,
|
||||
asu.regdate
|
||||
FROM authority_sub_user asu
|
||||
JOIN user_info ui ON asu.user_id = ui.user_id
|
||||
WHERE asu.master_objid = $1
|
||||
ORDER BY ui.user_name
|
||||
`;
|
||||
|
||||
const result = await query<RoleMember>(sql, [masterObjid]);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error("권한 그룹 멤버 조회 실패", { error, masterObjid });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 그룹 멤버 추가 (여러 명)
|
||||
*/
|
||||
static async addRoleMembers(
|
||||
masterObjid: number,
|
||||
userIds: string[],
|
||||
writer: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 이미 존재하는 멤버 제외
|
||||
const existingSql = `
|
||||
SELECT user_id
|
||||
FROM authority_sub_user
|
||||
WHERE master_objid = $1 AND user_id = ANY($2)
|
||||
`;
|
||||
const existing = await query<{ user_id: string }>(existingSql, [
|
||||
masterObjid,
|
||||
userIds,
|
||||
]);
|
||||
const existingIds = new Set(
|
||||
existing.map((row: { user_id: string }) => row.user_id)
|
||||
);
|
||||
|
||||
const newUserIds = userIds.filter((userId) => !existingIds.has(userId));
|
||||
|
||||
if (newUserIds.length === 0) {
|
||||
logger.info("추가할 신규 멤버가 없습니다", { masterObjid, userIds });
|
||||
return;
|
||||
}
|
||||
|
||||
// 배치 삽입
|
||||
const values = newUserIds
|
||||
.map(
|
||||
(_, index) =>
|
||||
`(nextval('seq_authority_sub_user'), $1, $${index + 2}, $${newUserIds.length + 2}, NOW())`
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
const sql = `
|
||||
INSERT INTO authority_sub_user (objid, master_objid, user_id, writer, regdate)
|
||||
VALUES ${values}
|
||||
`;
|
||||
|
||||
await query(sql, [masterObjid, ...newUserIds, writer]);
|
||||
|
||||
// 히스토리 기록
|
||||
for (const userId of newUserIds) {
|
||||
await this.insertAuthorityHistory(masterObjid, userId, "ADD", writer);
|
||||
}
|
||||
|
||||
logger.info("권한 그룹 멤버 추가 성공", {
|
||||
masterObjid,
|
||||
count: newUserIds.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("권한 그룹 멤버 추가 실패", { error, masterObjid, userIds });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 그룹 멤버 제거 (여러 명)
|
||||
*/
|
||||
static async removeRoleMembers(
|
||||
masterObjid: number,
|
||||
userIds: string[],
|
||||
writer: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await query(
|
||||
"DELETE FROM authority_sub_user WHERE master_objid = $1 AND user_id = ANY($2)",
|
||||
[masterObjid, userIds]
|
||||
);
|
||||
|
||||
// 히스토리 기록
|
||||
for (const userId of userIds) {
|
||||
await this.insertAuthorityHistory(
|
||||
masterObjid,
|
||||
userId,
|
||||
"REMOVE",
|
||||
writer
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("권한 그룹 멤버 제거 성공", {
|
||||
masterObjid,
|
||||
count: userIds.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("권한 그룹 멤버 제거 실패", { error, masterObjid, userIds });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 히스토리 기록
|
||||
*/
|
||||
private static async insertAuthorityHistory(
|
||||
masterObjid: number,
|
||||
userId: string,
|
||||
historyType: "ADD" | "REMOVE",
|
||||
writer: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const sql = `
|
||||
INSERT INTO authority_master_history
|
||||
(objid, parent_objid, parent_name, parent_code, user_id, active, history_type, writer, reg_date)
|
||||
SELECT
|
||||
nextval('seq_authority_master'),
|
||||
$1,
|
||||
am.auth_name,
|
||||
am.auth_code,
|
||||
$2,
|
||||
am.status,
|
||||
$3,
|
||||
$4,
|
||||
NOW()
|
||||
FROM authority_master am
|
||||
WHERE am.objid = $1
|
||||
`;
|
||||
|
||||
await query(sql, [masterObjid, userId, historyType, writer]);
|
||||
} catch (error) {
|
||||
logger.error("권한 히스토리 기록 실패", {
|
||||
error,
|
||||
masterObjid,
|
||||
userId,
|
||||
historyType,
|
||||
});
|
||||
// 히스토리 기록 실패는 메인 작업을 중단하지 않음
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 권한 목록 조회
|
||||
*/
|
||||
static async getMenuPermissions(
|
||||
authObjid: number
|
||||
): Promise<MenuPermission[]> {
|
||||
try {
|
||||
const sql = `
|
||||
SELECT
|
||||
rma.objid,
|
||||
rma.menu_objid AS "menuObjid",
|
||||
rma.auth_objid AS "authObjid",
|
||||
mi.menu_name_kor AS "menuName",
|
||||
mi.menu_code AS "menuCode",
|
||||
mi.menu_url AS "menuUrl",
|
||||
rma.create_yn AS "createYn",
|
||||
rma.read_yn AS "readYn",
|
||||
rma.update_yn AS "updateYn",
|
||||
rma.delete_yn AS "deleteYn",
|
||||
rma.execute_yn AS "executeYn",
|
||||
rma.export_yn AS "exportYn",
|
||||
rma.writer,
|
||||
rma.regdate
|
||||
FROM rel_menu_auth rma
|
||||
LEFT JOIN menu_info mi ON rma.menu_objid = mi.objid
|
||||
WHERE rma.auth_objid = $1
|
||||
ORDER BY mi.menu_name_kor
|
||||
`;
|
||||
|
||||
const result = await query<MenuPermission>(sql, [authObjid]);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error("메뉴 권한 조회 실패", { error, authObjid });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 권한 설정 (여러 메뉴)
|
||||
*/
|
||||
static async setMenuPermissions(
|
||||
authObjid: number,
|
||||
permissions: Array<{
|
||||
menuObjid: number;
|
||||
createYn: string;
|
||||
readYn: string;
|
||||
updateYn: string;
|
||||
deleteYn: string;
|
||||
}>,
|
||||
writer: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 기존 권한 삭제
|
||||
await query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [
|
||||
authObjid,
|
||||
]);
|
||||
|
||||
// 새로운 권한 삽입
|
||||
if (permissions.length > 0) {
|
||||
const values = permissions
|
||||
.map(
|
||||
(_, index) =>
|
||||
`(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())`
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
const params = permissions.flatMap((p) => [
|
||||
p.menuObjid,
|
||||
p.createYn,
|
||||
p.readYn,
|
||||
p.updateYn,
|
||||
p.deleteYn,
|
||||
]);
|
||||
|
||||
const sql = `
|
||||
INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate)
|
||||
VALUES ${values}
|
||||
`;
|
||||
|
||||
await query(sql, [authObjid, ...params, writer]);
|
||||
}
|
||||
|
||||
logger.info("메뉴 권한 설정 성공", {
|
||||
authObjid,
|
||||
count: permissions.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("메뉴 권한 설정 실패", { error, authObjid, permissions });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자가 속한 권한 그룹 목록 조회
|
||||
*/
|
||||
static async getUserRoleGroups(
|
||||
userId: string,
|
||||
companyCode: string
|
||||
): Promise<RoleGroup[]> {
|
||||
try {
|
||||
const sql = `
|
||||
SELECT
|
||||
am.objid,
|
||||
am.auth_name AS "authName",
|
||||
am.auth_code AS "authCode",
|
||||
am.company_code AS "companyCode",
|
||||
am.status,
|
||||
am.writer,
|
||||
am.regdate
|
||||
FROM authority_master am
|
||||
JOIN authority_sub_user asu ON am.objid = asu.master_objid
|
||||
WHERE asu.user_id = $1
|
||||
AND am.company_code = $2
|
||||
AND am.status = 'active'
|
||||
ORDER BY am.auth_name
|
||||
`;
|
||||
|
||||
const result = await query<RoleGroup>(sql, [userId, companyCode]);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error("사용자 권한 그룹 조회 실패", {
|
||||
error,
|
||||
userId,
|
||||
companyCode,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 메뉴 목록 조회 (권한 설정용)
|
||||
*/
|
||||
/**
|
||||
* 전체 메뉴 목록 조회 (권한 설정용)
|
||||
*/
|
||||
static async getAllMenus(companyCode?: string): Promise<any[]> {
|
||||
try {
|
||||
logger.info("🔍 전체 메뉴 목록 조회 시작", { companyCode });
|
||||
|
||||
let whereConditions: string[] = ["status = 'active'"];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 회사 코드 필터 (선택적)
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
/**
|
||||
* 전체 메뉴 목록 조회 (권한 설정용)
|
||||
*/
|
||||
static async getAllMenus(companyCode?: string): Promise<any[]> {
|
||||
try {
|
||||
logger.info("🔍 전체 메뉴 목록 조회 시작", { companyCode });
|
||||
|
||||
let whereConditions: string[] = ["status = 'active'"];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 회사 코드 필터 (선택적)
|
||||
// 공통 메뉴(*)와 특정 회사 메뉴를 모두 조회
|
||||
if (companyCode) {
|
||||
whereConditions.push(`(company_code = \$${paramIndex} OR company_code = '*')`);
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
logger.info("📋 회사 코드 필터 적용", { companyCode });
|
||||
} else {
|
||||
logger.info("📋 회사 코드 필터 없음 (전체 조회)");
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.join(" AND ");
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
objid,
|
||||
menu_name_kor AS "menuName",
|
||||
menu_name_eng AS "menuNameEng",
|
||||
menu_code AS "menuCode",
|
||||
menu_url AS "menuUrl",
|
||||
menu_type AS "menuType",
|
||||
parent_obj_id AS "parentObjid",
|
||||
seq AS "sortOrder",
|
||||
company_code AS "companyCode"
|
||||
FROM menu_info
|
||||
WHERE ${whereClause}
|
||||
ORDER BY seq, menu_name_kor
|
||||
`;
|
||||
|
||||
logger.info("🔍 SQL 쿼리 실행", {
|
||||
whereClause,
|
||||
params,
|
||||
sql: sql.substring(0, 200) + "...",
|
||||
});
|
||||
|
||||
const result = await query<any>(sql, params);
|
||||
|
||||
logger.info("✅ 메뉴 목록 조회 성공", {
|
||||
count: result.length,
|
||||
companyCode,
|
||||
menus: result.map((m) => ({
|
||||
objid: m.objid,
|
||||
name: m.menuName,
|
||||
code: m.menuCode,
|
||||
companyCode: m.companyCode,
|
||||
})),
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error("❌ 메뉴 목록 조회 실패", { error, companyCode });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { ChartConfig, QueryResult } from './types';
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { ChartConfig, QueryResult, MarkerColorRule } from "./types";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
interface VehicleMapConfigPanelProps {
|
||||
config?: ChartConfig;
|
||||
|
|
@ -18,24 +20,80 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {});
|
||||
|
||||
// 설정 업데이트
|
||||
const updateConfig = useCallback((updates: Partial<ChartConfig>) => {
|
||||
const newConfig = { ...currentConfig, ...updates };
|
||||
setCurrentConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
}, [currentConfig, onConfigChange]);
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<ChartConfig>) => {
|
||||
const newConfig = { ...currentConfig, ...updates };
|
||||
setCurrentConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
},
|
||||
[currentConfig, onConfigChange],
|
||||
);
|
||||
|
||||
// 사용 가능한 컬럼 목록
|
||||
const availableColumns = queryResult?.columns || [];
|
||||
const sampleData = queryResult?.rows?.[0] || {};
|
||||
|
||||
// 마커 색상 모드 변경
|
||||
const handleMarkerColorModeChange = useCallback(
|
||||
(mode: "single" | "conditional") => {
|
||||
if (mode === "single") {
|
||||
updateConfig({
|
||||
markerColorMode: "single",
|
||||
markerColorColumn: undefined,
|
||||
markerColorRules: undefined,
|
||||
markerDefaultColor: "#3b82f6", // 파란색
|
||||
});
|
||||
} else {
|
||||
updateConfig({
|
||||
markerColorMode: "conditional",
|
||||
markerColorRules: [],
|
||||
markerDefaultColor: "#6b7280", // 회색
|
||||
});
|
||||
}
|
||||
},
|
||||
[updateConfig],
|
||||
);
|
||||
|
||||
// 마커 색상 규칙 추가
|
||||
const addColorRule = useCallback(() => {
|
||||
const newRule: MarkerColorRule = {
|
||||
id: uuidv4(),
|
||||
value: "",
|
||||
color: "#3b82f6",
|
||||
label: "",
|
||||
};
|
||||
const currentRules = currentConfig.markerColorRules || [];
|
||||
updateConfig({ markerColorRules: [...currentRules, newRule] });
|
||||
}, [currentConfig.markerColorRules, updateConfig]);
|
||||
|
||||
// 마커 색상 규칙 삭제
|
||||
const deleteColorRule = useCallback(
|
||||
(id: string) => {
|
||||
const currentRules = currentConfig.markerColorRules || [];
|
||||
updateConfig({ markerColorRules: currentRules.filter((rule) => rule.id !== id) });
|
||||
},
|
||||
[currentConfig.markerColorRules, updateConfig],
|
||||
);
|
||||
|
||||
// 마커 색상 규칙 업데이트
|
||||
const updateColorRule = useCallback(
|
||||
(id: string, updates: Partial<MarkerColorRule>) => {
|
||||
const currentRules = currentConfig.markerColorRules || [];
|
||||
updateConfig({
|
||||
markerColorRules: currentRules.map((rule) => (rule.id === id ? { ...rule, ...updates } : rule)),
|
||||
});
|
||||
},
|
||||
[currentConfig.markerColorRules, updateConfig],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-semibold text-gray-800">🗺️ 지도 설정</h4>
|
||||
|
||||
{/* 쿼리 결과가 없을 때 */}
|
||||
{!queryResult && (
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="text-yellow-800 text-xs">
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-3">
|
||||
<div className="text-xs text-yellow-800">
|
||||
💡 먼저 SQL 쿼리를 실행하여 데이터를 가져온 후 지도를 설정할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -49,10 +107,10 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
<label className="block text-xs font-medium text-gray-700">지도 제목</label>
|
||||
<input
|
||||
type="text"
|
||||
value={currentConfig.title || ''}
|
||||
value={currentConfig.title || ""}
|
||||
onChange={(e) => updateConfig({ title: e.target.value })}
|
||||
placeholder="차량 위치 지도"
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -60,12 +118,12 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
위도 컬럼 (Latitude)
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.latitudeColumn || ''}
|
||||
value={currentConfig.latitudeColumn || ""}
|
||||
onChange={(e) => updateConfig({ latitudeColumn: e.target.value })}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{availableColumns.map((col) => (
|
||||
|
|
@ -80,12 +138,12 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
경도 컬럼 (Longitude)
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.longitudeColumn || ''}
|
||||
value={currentConfig.longitudeColumn || ""}
|
||||
onChange={(e) => updateConfig({ longitudeColumn: e.target.value })}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{availableColumns.map((col) => (
|
||||
|
|
@ -98,13 +156,11 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
|
||||
{/* 라벨 컬럼 (선택사항) */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
라벨 컬럼 (마커 표시명)
|
||||
</label>
|
||||
<label className="block text-xs font-medium text-gray-700">라벨 컬럼 (마커 표시명)</label>
|
||||
<select
|
||||
value={currentConfig.labelColumn || ''}
|
||||
value={currentConfig.labelColumn || ""}
|
||||
onChange={(e) => updateConfig({ labelColumn: e.target.value })}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<option value="">선택하세요 (선택사항)</option>
|
||||
{availableColumns.map((col) => (
|
||||
|
|
@ -115,74 +171,265 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
</select>
|
||||
</div>
|
||||
|
||||
{/* 상태 컬럼 (선택사항) */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
상태 컬럼 (마커 색상)
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.statusColumn || ''}
|
||||
onChange={(e) => updateConfig({ statusColumn: e.target.value })}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
>
|
||||
<option value="">선택하세요 (선택사항)</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* 마커 색상 설정 */}
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<h5 className="text-xs font-semibold text-gray-800">🎨 마커 색상 설정</h5>
|
||||
|
||||
{/* 색상 모드 선택 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">색상 모드</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMarkerColorModeChange("single")}
|
||||
className={`flex-1 rounded-lg border px-3 py-2 text-xs transition-colors ${
|
||||
(currentConfig.markerColorMode || "single") === "single"
|
||||
? "border-blue-300 bg-blue-50 font-medium text-blue-700"
|
||||
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
단일 색상
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMarkerColorModeChange("conditional")}
|
||||
className={`flex-1 rounded-lg border px-3 py-2 text-xs transition-colors ${
|
||||
currentConfig.markerColorMode === "conditional"
|
||||
? "border-blue-300 bg-blue-50 font-medium text-blue-700"
|
||||
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
조건부 색상
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 단일 색상 모드 */}
|
||||
{(currentConfig.markerColorMode || "single") === "single" && (
|
||||
<div className="space-y-1.5 rounded-lg bg-gray-50 p-3">
|
||||
<label className="block text-xs font-medium text-gray-700">마커 색상</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={currentConfig.markerDefaultColor || "#3b82f6"}
|
||||
onChange={(e) => updateConfig({ markerDefaultColor: e.target.value })}
|
||||
className="h-8 w-12 cursor-pointer rounded border border-gray-300"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={currentConfig.markerDefaultColor || "#3b82f6"}
|
||||
onChange={(e) => updateConfig({ markerDefaultColor: e.target.value })}
|
||||
placeholder="#3b82f6"
|
||||
className="flex-1 rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">모든 마커가 동일한 색상으로 표시됩니다</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 조건부 색상 모드 */}
|
||||
{currentConfig.markerColorMode === "conditional" && (
|
||||
<div className="space-y-2 rounded-lg bg-gray-50 p-3">
|
||||
{/* 색상 조건 컬럼 선택 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
색상 조건 컬럼
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.markerColorColumn || ""}
|
||||
onChange={(e) => updateConfig({ markerColorColumn: e.target.value })}
|
||||
className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500">이 컬럼의 값에 따라 마커 색상이 결정됩니다</p>
|
||||
</div>
|
||||
|
||||
{/* 기본 색상 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">기본 색상</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={currentConfig.markerDefaultColor || "#6b7280"}
|
||||
onChange={(e) => updateConfig({ markerDefaultColor: e.target.value })}
|
||||
className="h-8 w-12 cursor-pointer rounded border border-gray-300"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={currentConfig.markerDefaultColor || "#6b7280"}
|
||||
onChange={(e) => updateConfig({ markerDefaultColor: e.target.value })}
|
||||
placeholder="#6b7280"
|
||||
className="flex-1 rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">규칙에 매칭되지 않는 경우 사용할 색상</p>
|
||||
</div>
|
||||
|
||||
{/* 색상 규칙 목록 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-xs font-medium text-gray-700">색상 규칙</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addColorRule}
|
||||
className="flex items-center gap-1 rounded-lg bg-blue-500 px-2 py-1 text-xs text-white transition-colors hover:bg-blue-600"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 규칙 리스트 */}
|
||||
{(currentConfig.markerColorRules || []).length === 0 ? (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-3 text-center">
|
||||
<p className="text-xs text-gray-500">추가 버튼을 눌러 색상 규칙을 만드세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(currentConfig.markerColorRules || []).map((rule) => (
|
||||
<div key={rule.id} className="space-y-2 rounded-lg border border-gray-200 bg-white p-2">
|
||||
{/* 규칙 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-700">규칙</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteColorRule(rule.id)}
|
||||
className="text-red-500 transition-colors hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 조건 값 */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium text-gray-600">값 (조건)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.value}
|
||||
onChange={(e) => updateColorRule(rule.id, { value: e.target.value })}
|
||||
placeholder="예: active, inactive"
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 색상 */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium text-gray-600">색상</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={rule.color}
|
||||
onChange={(e) => updateColorRule(rule.id, { color: e.target.value })}
|
||||
className="h-8 w-12 cursor-pointer rounded border border-gray-300"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.color}
|
||||
onChange={(e) => updateColorRule(rule.id, { color: e.target.value })}
|
||||
placeholder="#3b82f6"
|
||||
className="flex-1 rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 라벨 (선택사항) */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium text-gray-600">라벨 (선택)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.label || ""}
|
||||
onChange={(e) => updateColorRule(rule.id, { label: e.target.value })}
|
||||
placeholder="예: 활성, 비활성"
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 날씨 정보 표시 옵션 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentConfig.showWeather || false}
|
||||
onChange={(e) => updateConfig({ showWeather: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<span>날씨 정보 표시</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 ml-6">
|
||||
마커 팝업에 해당 위치의 날씨 정보를 함께 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentConfig.showWeather || false}
|
||||
onChange={(e) => updateConfig({ showWeather: e.target.checked })}
|
||||
className="text-primary focus:ring-primary h-4 w-4 rounded border-gray-300 focus:ring-2"
|
||||
/>
|
||||
<span>날씨 정보 표시</span>
|
||||
</label>
|
||||
<p className="ml-6 text-xs text-gray-500">마커 팝업에 해당 위치의 날씨 정보를 함께 표시합니다</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentConfig.showWeatherAlerts || false}
|
||||
onChange={(e) => updateConfig({ showWeatherAlerts: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<span>기상특보 영역 표시</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 ml-6">
|
||||
현재 발효 중인 기상특보(주의보/경보)를 지도에 색상 영역으로 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentConfig.showWeatherAlerts || false}
|
||||
onChange={(e) => updateConfig({ showWeatherAlerts: e.target.checked })}
|
||||
className="text-primary focus:ring-primary h-4 w-4 rounded border-gray-300 focus:ring-2"
|
||||
/>
|
||||
<span>기상특보 영역 표시</span>
|
||||
</label>
|
||||
<p className="ml-6 text-xs text-gray-500">
|
||||
현재 발효 중인 기상특보(주의보/경보)를 지도에 색상 영역으로 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 설정 미리보기 */}
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-xs font-medium text-gray-700 mb-2">📋 설정 미리보기</div>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div><strong>위도:</strong> {currentConfig.latitudeColumn || '미설정'}</div>
|
||||
<div><strong>경도:</strong> {currentConfig.longitudeColumn || '미설정'}</div>
|
||||
<div><strong>라벨:</strong> {currentConfig.labelColumn || '없음'}</div>
|
||||
<div><strong>상태:</strong> {currentConfig.statusColumn || '없음'}</div>
|
||||
<div><strong>날씨 표시:</strong> {currentConfig.showWeather ? '활성화' : '비활성화'}</div>
|
||||
<div><strong>기상특보 표시:</strong> {currentConfig.showWeatherAlerts ? '활성화' : '비활성화'}</div>
|
||||
<div><strong>데이터 개수:</strong> {queryResult.rows.length}개</div>
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
<div className="mb-2 text-xs font-medium text-gray-700">📋 설정 미리보기</div>
|
||||
<div className="text-muted-foreground space-y-1 text-xs">
|
||||
<div>
|
||||
<strong>위도:</strong> {currentConfig.latitudeColumn || "미설정"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>경도:</strong> {currentConfig.longitudeColumn || "미설정"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>라벨:</strong> {currentConfig.labelColumn || "없음"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>색상 모드:</strong> {currentConfig.markerColorMode === "conditional" ? "조건부" : "단일"}
|
||||
</div>
|
||||
{currentConfig.markerColorMode === "conditional" && (
|
||||
<>
|
||||
<div>
|
||||
<strong>색상 조건 컬럼:</strong> {currentConfig.markerColorColumn || "미설정"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>색상 규칙 개수:</strong> {(currentConfig.markerColorRules || []).length}개
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<strong>날씨 표시:</strong> {currentConfig.showWeather ? "활성화" : "비활성화"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>기상특보 표시:</strong> {currentConfig.showWeatherAlerts ? "활성화" : "비활성화"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>데이터 개수:</strong> {queryResult.rows.length}개
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필수 필드 확인 */}
|
||||
{(!currentConfig.latitudeColumn || !currentConfig.longitudeColumn) && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="text-red-800 text-xs">
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<div className="text-xs text-red-800">
|
||||
⚠️ 위도와 경도 컬럼을 반드시 선택해야 지도에 표시할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -192,4 +439,3 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -205,6 +205,20 @@ export interface ChartConfig {
|
|||
statusColumn?: string; // 상태 컬럼
|
||||
showWeather?: boolean; // 날씨 정보 표시 여부
|
||||
showWeatherAlerts?: boolean; // 기상특보 영역 표시 여부
|
||||
|
||||
// 마커 색상 설정
|
||||
markerColorMode?: "single" | "conditional"; // 마커 색상 모드 (단일/조건부)
|
||||
markerColorColumn?: string; // 색상 조건 컬럼
|
||||
markerColorRules?: MarkerColorRule[]; // 색상 규칙 배열
|
||||
markerDefaultColor?: string; // 기본 마커 색상
|
||||
}
|
||||
|
||||
// 마커 색상 규칙
|
||||
export interface MarkerColorRule {
|
||||
id: string; // 고유 ID
|
||||
value: string; // 컬럼 값 (예: "active", "inactive")
|
||||
color: string; // 마커 색상 (hex)
|
||||
label?: string; // 라벨 (선택사항)
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ interface MarkerData {
|
|||
name: string;
|
||||
info: any;
|
||||
weather?: WeatherData | null;
|
||||
markerColor?: string; // 마커 색상
|
||||
}
|
||||
|
||||
// 테이블명 한글 번역
|
||||
|
|
@ -472,6 +473,33 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
const latCol = element.chartConfig?.latitudeColumn || "latitude";
|
||||
const lngCol = element.chartConfig?.longitudeColumn || "longitude";
|
||||
|
||||
// 마커 색상 결정 함수
|
||||
const getMarkerColor = (row: any): string => {
|
||||
const colorMode = element.chartConfig?.markerColorMode || "single";
|
||||
|
||||
if (colorMode === "single") {
|
||||
// 단일 색상 모드
|
||||
return element.chartConfig?.markerDefaultColor || "#3b82f6";
|
||||
} else {
|
||||
// 조건부 색상 모드
|
||||
const colorColumn = element.chartConfig?.markerColorColumn;
|
||||
const colorRules = element.chartConfig?.markerColorRules || [];
|
||||
const defaultColor = element.chartConfig?.markerDefaultColor || "#6b7280";
|
||||
|
||||
if (!colorColumn || colorRules.length === 0) {
|
||||
return defaultColor;
|
||||
}
|
||||
|
||||
// 컬럼 값 가져오기
|
||||
const columnValue = String(row[colorColumn] || "");
|
||||
|
||||
// 색상 규칙 매칭
|
||||
const matchedRule = colorRules.find((rule) => String(rule.value) === columnValue);
|
||||
|
||||
return matchedRule ? matchedRule.color : defaultColor;
|
||||
}
|
||||
};
|
||||
|
||||
// 유효한 좌표 필터링 및 마커 데이터 생성
|
||||
const markerData = rows
|
||||
.filter((row: any) => row[latCol] && row[lngCol])
|
||||
|
|
@ -481,6 +509,7 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
name: row.name || row.vehicle_number || row.warehouse_name || row.customer_name || "알 수 없음",
|
||||
info: row,
|
||||
weather: null,
|
||||
markerColor: getMarkerColor(row), // 마커 색상 추가
|
||||
}));
|
||||
|
||||
setMarkers(markerData);
|
||||
|
|
@ -693,54 +722,81 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
})}
|
||||
|
||||
{/* 마커 표시 */}
|
||||
{markers.map((marker, idx) => (
|
||||
<Marker key={idx} position={[marker.lat, marker.lng]}>
|
||||
<Popup>
|
||||
<div className="min-w-[200px] text-xs">
|
||||
{/* 마커 정보 */}
|
||||
<div className="mb-2 border-b pb-2">
|
||||
<div className="mb-1 text-sm font-bold">{marker.name}</div>
|
||||
{Object.entries(marker.info)
|
||||
.filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase()))
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="text-xs">
|
||||
<strong>{key}:</strong> {String(value)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{markers.map((marker, idx) => {
|
||||
// Leaflet 커스텀 아이콘 생성 (클라이언트 사이드에서만)
|
||||
let customIcon;
|
||||
if (typeof window !== "undefined") {
|
||||
const L = require("leaflet");
|
||||
customIcon = L.divIcon({
|
||||
className: "custom-marker",
|
||||
html: `
|
||||
<div style="
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background-color: ${marker.markerColor || "#3b82f6"};
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translate(-50%, -50%);
|
||||
"></div>
|
||||
`,
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15],
|
||||
});
|
||||
}
|
||||
|
||||
{/* 날씨 정보 */}
|
||||
{marker.weather && (
|
||||
<div className="space-y-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
{getWeatherIcon(marker.weather.weatherMain)}
|
||||
<span className="text-xs font-semibold">현재 날씨</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">{marker.weather.weatherDescription}</div>
|
||||
<div className="mt-2 space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">온도</span>
|
||||
<span className="font-medium">{marker.weather.temperature}°C</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">체감온도</span>
|
||||
<span className="font-medium">{marker.weather.feelsLike}°C</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">습도</span>
|
||||
<span className="font-medium">{marker.weather.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">풍속</span>
|
||||
<span className="font-medium">{marker.weather.windSpeed} m/s</span>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<Marker key={idx} position={[marker.lat, marker.lng]} icon={customIcon}>
|
||||
<Popup>
|
||||
<div className="min-w-[200px] text-xs">
|
||||
{/* 마커 정보 */}
|
||||
<div className="mb-2 border-b pb-2">
|
||||
<div className="mb-1 text-sm font-bold">{marker.name}</div>
|
||||
{Object.entries(marker.info)
|
||||
.filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase()))
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="text-xs">
|
||||
<strong>{key}:</strong> {String(value)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* 날씨 정보 */}
|
||||
{marker.weather && (
|
||||
<div className="space-y-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
{getWeatherIcon(marker.weather.weatherMain)}
|
||||
<span className="text-xs font-semibold">현재 날씨</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">{marker.weather.weatherDescription}</div>
|
||||
<div className="mt-2 space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">온도</span>
|
||||
<span className="font-medium">{marker.weather.temperature}°C</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">체감온도</span>
|
||||
<span className="font-medium">{marker.weather.feelsLike}°C</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">습도</span>
|
||||
<span className="font-medium">{marker.weather.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">풍속</span>
|
||||
<span className="font-medium">{marker.weather.windSpeed} m/s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
|
||||
{/* 범례 (특보가 있을 때만 표시) */}
|
||||
|
|
|
|||
Loading…
Reference in New Issue