Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management

This commit is contained in:
kjs 2025-10-28 13:39:40 +09:00
commit 025fe04192
5 changed files with 443 additions and 747 deletions

View File

@ -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;
// 회사 코드 필터 (선택적)

View File

@ -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;
}
}
}

View File

@ -1,7 +1,9 @@
'use client'; "use client";
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from "react";
import { ChartConfig, QueryResult } from './types'; import { ChartConfig, QueryResult, MarkerColorRule } from "./types";
import { Plus, Trash2 } from "lucide-react";
import { v4 as uuidv4 } from "uuid";
interface VehicleMapConfigPanelProps { interface VehicleMapConfigPanelProps {
config?: ChartConfig; config?: ChartConfig;
@ -18,24 +20,80 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {}); const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {});
// 설정 업데이트 // 설정 업데이트
const updateConfig = useCallback((updates: Partial<ChartConfig>) => { const updateConfig = useCallback(
const newConfig = { ...currentConfig, ...updates }; (updates: Partial<ChartConfig>) => {
setCurrentConfig(newConfig); const newConfig = { ...currentConfig, ...updates };
onConfigChange(newConfig); setCurrentConfig(newConfig);
}, [currentConfig, onConfigChange]); onConfigChange(newConfig);
},
[currentConfig, onConfigChange],
);
// 사용 가능한 컬럼 목록 // 사용 가능한 컬럼 목록
const availableColumns = queryResult?.columns || []; const availableColumns = queryResult?.columns || [];
const sampleData = queryResult?.rows?.[0] || {}; 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 ( return (
<div className="space-y-3"> <div className="space-y-3">
<h4 className="text-xs font-semibold text-gray-800">🗺 </h4> <h4 className="text-xs font-semibold text-gray-800">🗺 </h4>
{/* 쿼리 결과가 없을 때 */} {/* 쿼리 결과가 없을 때 */}
{!queryResult && ( {!queryResult && (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg"> <div className="rounded-lg border border-yellow-200 bg-yellow-50 p-3">
<div className="text-yellow-800 text-xs"> <div className="text-xs text-yellow-800">
💡 SQL . 💡 SQL .
</div> </div>
</div> </div>
@ -49,10 +107,10 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
<label className="block text-xs font-medium text-gray-700"> </label> <label className="block text-xs font-medium text-gray-700"> </label>
<input <input
type="text" type="text"
value={currentConfig.title || ''} value={currentConfig.title || ""}
onChange={(e) => updateConfig({ title: e.target.value })} onChange={(e) => updateConfig({ title: e.target.value })}
placeholder="차량 위치 지도" 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> </div>
@ -60,12 +118,12 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-xs font-medium text-gray-700"> <label className="block text-xs font-medium text-gray-700">
(Latitude) (Latitude)
<span className="text-red-500 ml-1">*</span> <span className="ml-1 text-red-500">*</span>
</label> </label>
<select <select
value={currentConfig.latitudeColumn || ''} value={currentConfig.latitudeColumn || ""}
onChange={(e) => updateConfig({ latitudeColumn: e.target.value })} 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> <option value=""></option>
{availableColumns.map((col) => ( {availableColumns.map((col) => (
@ -80,12 +138,12 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-xs font-medium text-gray-700"> <label className="block text-xs font-medium text-gray-700">
(Longitude) (Longitude)
<span className="text-red-500 ml-1">*</span> <span className="ml-1 text-red-500">*</span>
</label> </label>
<select <select
value={currentConfig.longitudeColumn || ''} value={currentConfig.longitudeColumn || ""}
onChange={(e) => updateConfig({ longitudeColumn: e.target.value })} 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> <option value=""></option>
{availableColumns.map((col) => ( {availableColumns.map((col) => (
@ -98,13 +156,11 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
{/* 라벨 컬럼 (선택사항) */} {/* 라벨 컬럼 (선택사항) */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-xs font-medium text-gray-700"> <label className="block text-xs font-medium text-gray-700"> ( )</label>
( )
</label>
<select <select
value={currentConfig.labelColumn || ''} value={currentConfig.labelColumn || ""}
onChange={(e) => updateConfig({ labelColumn: e.target.value })} 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> <option value=""> ()</option>
{availableColumns.map((col) => ( {availableColumns.map((col) => (
@ -115,74 +171,265 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
</select> </select>
</div> </div>
{/* 상태 컬럼 (선택사항) */} {/* 마커 색상 설정 */}
<div className="space-y-1.5"> <div className="space-y-2 border-t pt-3">
<label className="block text-xs font-medium text-gray-700"> <h5 className="text-xs font-semibold text-gray-800">🎨 </h5>
( )
</label> {/* 색상 모드 선택 */}
<select <div className="space-y-1.5">
value={currentConfig.statusColumn || ''} <label className="block text-xs font-medium text-gray-700"> </label>
onChange={(e) => updateConfig({ statusColumn: e.target.value })} <div className="flex gap-2">
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs" <button
> type="button"
<option value=""> ()</option> onClick={() => handleMarkerColorModeChange("single")}
{availableColumns.map((col) => ( className={`flex-1 rounded-lg border px-3 py-2 text-xs transition-colors ${
<option key={col} value={col}> (currentConfig.markerColorMode || "single") === "single"
{col} ? "border-blue-300 bg-blue-50 font-medium text-blue-700"
</option> : "border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
))} }`}
</select> >
</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>
{/* 날씨 정보 표시 옵션 */} {/* 날씨 정보 표시 옵션 */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="flex items-center gap-2 text-xs font-medium text-gray-700 cursor-pointer"> <label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-gray-700">
<input <input
type="checkbox" type="checkbox"
checked={currentConfig.showWeather || false} checked={currentConfig.showWeather || false}
onChange={(e) => updateConfig({ showWeather: e.target.checked })} onChange={(e) => updateConfig({ showWeather: e.target.checked })}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary" className="text-primary focus:ring-primary h-4 w-4 rounded border-gray-300 focus:ring-2"
/> />
<span> </span> <span> </span>
</label> </label>
<p className="text-xs text-gray-500 ml-6"> <p className="ml-6 text-xs text-gray-500"> </p>
</div>
</p>
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="flex items-center gap-2 text-xs font-medium text-gray-700 cursor-pointer"> <label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-gray-700">
<input <input
type="checkbox" type="checkbox"
checked={currentConfig.showWeatherAlerts || false} checked={currentConfig.showWeatherAlerts || false}
onChange={(e) => updateConfig({ showWeatherAlerts: e.target.checked })} onChange={(e) => updateConfig({ showWeatherAlerts: e.target.checked })}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary" className="text-primary focus:ring-primary h-4 w-4 rounded border-gray-300 focus:ring-2"
/> />
<span> </span> <span> </span>
</label> </label>
<p className="text-xs text-gray-500 ml-6"> <p className="ml-6 text-xs text-gray-500">
(/) (/)
</p> </p>
</div> </div>
{/* 설정 미리보기 */} {/* 설정 미리보기 */}
<div className="p-3 bg-gray-50 rounded-lg"> <div className="rounded-lg bg-gray-50 p-3">
<div className="text-xs font-medium text-gray-700 mb-2">📋 </div> <div className="mb-2 text-xs font-medium text-gray-700">📋 </div>
<div className="text-xs text-muted-foreground space-y-1"> <div className="text-muted-foreground space-y-1 text-xs">
<div><strong>:</strong> {currentConfig.latitudeColumn || '미설정'}</div> <div>
<div><strong>:</strong> {currentConfig.longitudeColumn || '미설정'}</div> <strong>:</strong> {currentConfig.latitudeColumn || "미설정"}
<div><strong>:</strong> {currentConfig.labelColumn || '없음'}</div> </div>
<div><strong>:</strong> {currentConfig.statusColumn || '없음'}</div> <div>
<div><strong> :</strong> {currentConfig.showWeather ? '활성화' : '비활성화'}</div> <strong>:</strong> {currentConfig.longitudeColumn || "미설정"}
<div><strong> :</strong> {currentConfig.showWeatherAlerts ? '활성화' : '비활성화'}</div> </div>
<div><strong> :</strong> {queryResult.rows.length}</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>
</div> </div>
{/* 필수 필드 확인 */} {/* 필수 필드 확인 */}
{(!currentConfig.latitudeColumn || !currentConfig.longitudeColumn) && ( {(!currentConfig.latitudeColumn || !currentConfig.longitudeColumn) && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg"> <div className="rounded-lg border border-red-200 bg-red-50 p-3">
<div className="text-red-800 text-xs"> <div className="text-xs text-red-800">
. .
</div> </div>
</div> </div>
@ -192,4 +439,3 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
</div> </div>
); );
} }

View File

@ -205,6 +205,20 @@ export interface ChartConfig {
statusColumn?: string; // 상태 컬럼 statusColumn?: string; // 상태 컬럼
showWeather?: boolean; // 날씨 정보 표시 여부 showWeather?: boolean; // 날씨 정보 표시 여부
showWeatherAlerts?: 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 { export interface QueryResult {

View File

@ -42,6 +42,7 @@ interface MarkerData {
name: string; name: string;
info: any; info: any;
weather?: WeatherData | null; weather?: WeatherData | null;
markerColor?: string; // 마커 색상
} }
// 테이블명 한글 번역 // 테이블명 한글 번역
@ -472,6 +473,33 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
const latCol = element.chartConfig?.latitudeColumn || "latitude"; const latCol = element.chartConfig?.latitudeColumn || "latitude";
const lngCol = element.chartConfig?.longitudeColumn || "longitude"; 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 const markerData = rows
.filter((row: any) => row[latCol] && row[lngCol]) .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 || "알 수 없음", name: row.name || row.vehicle_number || row.warehouse_name || row.customer_name || "알 수 없음",
info: row, info: row,
weather: null, weather: null,
markerColor: getMarkerColor(row), // 마커 색상 추가
})); }));
setMarkers(markerData); setMarkers(markerData);
@ -693,54 +722,81 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
})} })}
{/* 마커 표시 */} {/* 마커 표시 */}
{markers.map((marker, idx) => ( {markers.map((marker, idx) => {
<Marker key={idx} position={[marker.lat, marker.lng]}> // Leaflet 커스텀 아이콘 생성 (클라이언트 사이드에서만)
<Popup> let customIcon;
<div className="min-w-[200px] text-xs"> if (typeof window !== "undefined") {
{/* 마커 정보 */} const L = require("leaflet");
<div className="mb-2 border-b pb-2"> customIcon = L.divIcon({
<div className="mb-1 text-sm font-bold">{marker.name}</div> className: "custom-marker",
{Object.entries(marker.info) html: `
.filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase())) <div style="
.map(([key, value]) => ( width: 30px;
<div key={key} className="text-xs"> height: 30px;
<strong>{key}:</strong> {String(value)} background-color: ${marker.markerColor || "#3b82f6"};
</div> border: 3px solid white;
))} border-radius: 50%;
</div> 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],
});
}
{/* 날씨 정보 */} return (
{marker.weather && ( <Marker key={idx} position={[marker.lat, marker.lng]} icon={customIcon}>
<div className="space-y-1"> <Popup>
<div className="mb-1 flex items-center gap-2"> <div className="min-w-[200px] text-xs">
{getWeatherIcon(marker.weather.weatherMain)} {/* 마커 정보 */}
<span className="text-xs font-semibold"> </span> <div className="mb-2 border-b pb-2">
</div> <div className="mb-1 text-sm font-bold">{marker.name}</div>
<div className="text-xs text-gray-600">{marker.weather.weatherDescription}</div> {Object.entries(marker.info)
<div className="mt-2 space-y-1 text-xs"> .filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase()))
<div className="flex justify-between"> .map(([key, value]) => (
<span className="text-gray-500"></span> <div key={key} className="text-xs">
<span className="font-medium">{marker.weather.temperature}°C</span> <strong>{key}:</strong> {String(value)}
</div> </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>
)}
</div> {/* 날씨 정보 */}
</Popup> {marker.weather && (
</Marker> <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> </MapContainer>
{/* 범례 (특보가 있을 때만 표시) */} {/* 범례 (특보가 있을 때만 표시) */}