대시보드관리디벨롭
This commit is contained in:
parent
40e9958690
commit
86135dcf10
|
|
@ -4,13 +4,14 @@
|
|||
"title": "ㅁㄴㅇㄹ",
|
||||
"description": "ㅁㄴㅇㄹ",
|
||||
"priority": "normal",
|
||||
"status": "pending",
|
||||
"status": "completed",
|
||||
"assignedTo": "",
|
||||
"dueDate": "2025-10-20T18:17",
|
||||
"createdAt": "2025-10-20T06:15:49.610Z",
|
||||
"updatedAt": "2025-10-20T06:15:49.610Z",
|
||||
"updatedAt": "2025-10-20T07:36:06.370Z",
|
||||
"isUrgent": false,
|
||||
"order": 0
|
||||
"order": 0,
|
||||
"completedAt": "2025-10-20T07:36:06.370Z"
|
||||
},
|
||||
{
|
||||
"id": "334be17c-7776-47e8-89ec-4b57c4a34bcd",
|
||||
|
|
|
|||
|
|
@ -739,7 +739,7 @@ export function CanvasElement({
|
|||
isEditMode={true}
|
||||
config={element.yardConfig}
|
||||
onConfigChange={(newConfig) => {
|
||||
console.log("🏗️ 야드 설정 업데이트:", { elementId: element.id, newConfig });
|
||||
// console.log("🏗️ 야드 설정 업데이트:", { elementId: element.id, newConfig });
|
||||
onUpdate(element.id, { yardConfig: newConfig });
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -335,13 +335,13 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
|
||||
const elementsData = elements.map((el) => {
|
||||
// 야드 위젯인 경우 설정 로그 출력
|
||||
if (el.subtype === "yard-management-3d") {
|
||||
console.log("💾 야드 위젯 저장:", {
|
||||
id: el.id,
|
||||
yardConfig: el.yardConfig,
|
||||
hasLayoutId: !!el.yardConfig?.layoutId,
|
||||
});
|
||||
}
|
||||
// if (el.subtype === "yard-management-3d") {
|
||||
// console.log("💾 야드 위젯 저장:", {
|
||||
// id: el.id,
|
||||
// yardConfig: el.yardConfig,
|
||||
// hasLayoutId: !!el.yardConfig?.layoutId,
|
||||
// });
|
||||
// }
|
||||
return {
|
||||
id: el.id,
|
||||
type: el.type,
|
||||
|
|
|
|||
|
|
@ -200,12 +200,13 @@ export function DashboardSidebar() {
|
|||
subtype="todo"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
{/* 예약알림 위젯 - 필요시 주석 해제 */}
|
||||
{/* <DraggableItem
|
||||
title="예약 요청 알림"
|
||||
type="widget"
|
||||
subtype="booking-alert"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
/> */}
|
||||
{/* 정비 일정 관리 위젯 제거 - 커스텀 목록 카드로 대체 가능 */}
|
||||
<DraggableItem
|
||||
title="문서 다운로드"
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ export function DashboardTopMenu({
|
|||
<SelectItem value="calendar">달력</SelectItem>
|
||||
<SelectItem value="clock">시계</SelectItem>
|
||||
<SelectItem value="todo">할 일</SelectItem>
|
||||
<SelectItem value="booking-alert">예약 알림</SelectItem>
|
||||
{/* <SelectItem value="booking-alert">예약 알림</SelectItem> */}
|
||||
<SelectItem value="maintenance">정비 일정</SelectItem>
|
||||
<SelectItem value="document">문서</SelectItem>
|
||||
<SelectItem value="risk-alert">리스크 알림</SelectItem>
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export default function YardManagement3DWidget({
|
|||
// 레이아웃 목록이 로드되었고, 설정이 없으면 첫 번째 레이아웃 자동 선택
|
||||
useEffect(() => {
|
||||
if (isEditMode && layouts.length > 0 && !config?.layoutId && onConfigChange) {
|
||||
console.log("🔧 첫 번째 야드 레이아웃 자동 선택:", layouts[0]);
|
||||
// console.log("🔧 첫 번째 야드 레이아웃 자동 선택:", layouts[0]);
|
||||
onConfigChange({
|
||||
layoutId: layouts[0].id,
|
||||
layoutName: layouts[0].name,
|
||||
|
|
|
|||
|
|
@ -90,20 +90,26 @@ function renderWidget(element: DashboardElement) {
|
|||
return <ListWidget element={element} />;
|
||||
|
||||
case "yard-management-3d":
|
||||
console.log("🏗️ 야드관리 위젯 렌더링:", {
|
||||
elementId: element.id,
|
||||
yardConfig: element.yardConfig,
|
||||
yardConfigType: typeof element.yardConfig,
|
||||
hasLayoutId: !!element.yardConfig?.layoutId,
|
||||
layoutId: element.yardConfig?.layoutId,
|
||||
layoutName: element.yardConfig?.layoutName,
|
||||
});
|
||||
// console.log("🏗️ 야드관리 위젯 렌더링:", {
|
||||
// elementId: element.id,
|
||||
// yardConfig: element.yardConfig,
|
||||
// yardConfigType: typeof element.yardConfig,
|
||||
// hasLayoutId: !!element.yardConfig?.layoutId,
|
||||
// layoutId: element.yardConfig?.layoutId,
|
||||
// layoutName: element.yardConfig?.layoutName,
|
||||
// });
|
||||
return <YardManagement3DWidget isEditMode={false} config={element.yardConfig} />;
|
||||
|
||||
case "work-history":
|
||||
return <WorkHistoryWidget element={element} />;
|
||||
|
||||
case "transport-stats":
|
||||
// console.log("📊 [DashboardViewer] CustomStatsWidget 렌더링:", {
|
||||
// elementId: element.id,
|
||||
// hasDataSource: !!element.dataSource,
|
||||
// query: element.dataSource?.query?.substring(0, 50) + "...",
|
||||
// dataSourceType: element.dataSource?.type,
|
||||
// });
|
||||
return <CustomStatsWidget element={element} />;
|
||||
|
||||
// === 차량 관련 (추가 위젯) ===
|
||||
|
|
|
|||
|
|
@ -24,31 +24,46 @@ interface StatItem {
|
|||
}
|
||||
|
||||
export default function CustomStatsWidget({ element, refreshInterval = 60000 }: CustomStatsWidgetProps) {
|
||||
// console.log("🚀 CustomStatsWidget 마운트:", {
|
||||
// elementId: element?.id,
|
||||
// query: element?.dataSource?.query?.substring(0, 50) + "...",
|
||||
// hasDataSource: !!element?.dataSource,
|
||||
// });
|
||||
|
||||
const [allStats, setAllStats] = useState<StatItem[]>([]); // 모든 통계
|
||||
const [stats, setStats] = useState<StatItem[]>([]); // 표시할 통계
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [selectedStats, setSelectedStats] = useState<string[]>([]); // 선택된 통계 라벨
|
||||
const selectedStatsRef = React.useRef<string[]>([]); // 현재 선택된 통계를 추적하는 ref
|
||||
const isInitializedRef = React.useRef(false); // 초기화 여부 추적
|
||||
const lastQueryRef = React.useRef<string>(""); // 마지막 쿼리 추적
|
||||
|
||||
// localStorage 키 생성 (위젯별로 고유하게)
|
||||
const storageKey = `custom-stats-widget-${element?.id || "default"}`;
|
||||
// localStorage 키 생성 (쿼리 기반으로 고유하게 - 편집/보기 모드 공유)
|
||||
const queryHash = element?.dataSource?.query
|
||||
? btoa(element.dataSource.query) // 전체 쿼리를 base64로 인코딩
|
||||
: "default";
|
||||
const storageKey = `custom-stats-widget-${queryHash}`;
|
||||
|
||||
// console.log("🔑 storageKey:", storageKey, "(쿼리:", element?.dataSource?.query?.substring(0, 30) + "...)");
|
||||
|
||||
// 초기 로드 시 저장된 설정 불러오기
|
||||
// 쿼리가 변경되면 초기화 상태 리셋
|
||||
React.useEffect(() => {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
setSelectedStats(parsed);
|
||||
} catch (e) {
|
||||
console.error("설정 로드 실패:", e);
|
||||
}
|
||||
const currentQuery = element?.dataSource?.query || "";
|
||||
if (currentQuery !== lastQueryRef.current) {
|
||||
isInitializedRef.current = false;
|
||||
lastQueryRef.current = currentQuery;
|
||||
}
|
||||
}, [storageKey]);
|
||||
}, [element?.dataSource?.query]);
|
||||
|
||||
// selectedStats 변경 시 ref 업데이트
|
||||
React.useEffect(() => {
|
||||
selectedStatsRef.current = selectedStats;
|
||||
}, [selectedStats]);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = async () => {
|
||||
const loadData = React.useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
|
@ -130,90 +145,204 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
}
|
||||
});
|
||||
|
||||
// 3. 키워드 기반 자동 라벨링 및 단위 설정
|
||||
// 3. 컬럼명 한글 번역 매핑
|
||||
const columnNameTranslation: { [key: string]: string } = {
|
||||
// 일반
|
||||
"id": "ID",
|
||||
"name": "이름",
|
||||
"title": "제목",
|
||||
"description": "설명",
|
||||
"status": "상태",
|
||||
"type": "유형",
|
||||
"category": "카테고리",
|
||||
"date": "날짜",
|
||||
"time": "시간",
|
||||
"created_at": "생성일",
|
||||
"updated_at": "수정일",
|
||||
"deleted_at": "삭제일",
|
||||
|
||||
// 물류/운송
|
||||
"tracking_number": "운송장 번호",
|
||||
"customer": "고객",
|
||||
"origin": "출발지",
|
||||
"destination": "목적지",
|
||||
"estimated_delivery": "예상 도착",
|
||||
"actual_delivery": "실제 도착",
|
||||
"delay_reason": "지연 사유",
|
||||
"priority": "우선순위",
|
||||
"cargo_weight": "화물 중량",
|
||||
"total_weight": "총 중량",
|
||||
"weight": "중량",
|
||||
"distance": "거리",
|
||||
"total_distance": "총 거리",
|
||||
"delivery_time": "배송 시간",
|
||||
"delivery_duration": "배송 소요시간",
|
||||
"is_on_time": "정시 도착 여부",
|
||||
"on_time": "정시",
|
||||
|
||||
// 수량/금액
|
||||
"quantity": "수량",
|
||||
"qty": "수량",
|
||||
"amount": "금액",
|
||||
"price": "가격",
|
||||
"cost": "비용",
|
||||
"fee": "수수료",
|
||||
"total": "합계",
|
||||
"sum": "총합",
|
||||
|
||||
// 비율/효율
|
||||
"rate": "비율",
|
||||
"ratio": "비율",
|
||||
"percent": "퍼센트",
|
||||
"percentage": "백분율",
|
||||
"efficiency": "효율",
|
||||
|
||||
// 생산/처리
|
||||
"throughput": "처리량",
|
||||
"output": "산출량",
|
||||
"production": "생산량",
|
||||
"volume": "용량",
|
||||
|
||||
// 재고/설비
|
||||
"stock": "재고",
|
||||
"inventory": "재고",
|
||||
"equipment": "설비",
|
||||
"facility": "시설",
|
||||
"machine": "기계",
|
||||
|
||||
// 평가
|
||||
"score": "점수",
|
||||
"rating": "평점",
|
||||
"point": "점수",
|
||||
"grade": "등급",
|
||||
|
||||
// 기타
|
||||
"temperature": "온도",
|
||||
"temp": "온도",
|
||||
"speed": "속도",
|
||||
"velocity": "속도",
|
||||
"count": "개수",
|
||||
"number": "번호",
|
||||
};
|
||||
|
||||
// 4. 키워드 기반 자동 라벨링 및 단위 설정
|
||||
const columnConfig: {
|
||||
[key: string]: {
|
||||
keywords: string[];
|
||||
unit: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
useAvg?: boolean;
|
||||
aggregation: "sum" | "avg" | "max" | "min"; // 집계 방식
|
||||
koreanLabel?: string; // 한글 라벨
|
||||
};
|
||||
} = {
|
||||
// 무게/중량
|
||||
// 무게/중량 - 합계
|
||||
weight: {
|
||||
keywords: ["weight", "cargo_weight", "total_weight", "tonnage"],
|
||||
keywords: ["weight", "cargo_weight", "total_weight", "tonnage", "ton"],
|
||||
unit: "톤",
|
||||
color: "green",
|
||||
icon: "⚖️",
|
||||
aggregation: "sum",
|
||||
koreanLabel: "총 운송량"
|
||||
},
|
||||
// 거리
|
||||
// 거리 - 합계
|
||||
distance: {
|
||||
keywords: ["distance", "total_distance"],
|
||||
keywords: ["distance", "total_distance", "km", "kilometer"],
|
||||
unit: "km",
|
||||
color: "blue",
|
||||
icon: "🛣️",
|
||||
aggregation: "sum",
|
||||
koreanLabel: "누적 거리"
|
||||
},
|
||||
// 시간/기간
|
||||
// 시간/기간 - 평균
|
||||
time: {
|
||||
keywords: ["time", "duration", "delivery_time", "delivery_duration"],
|
||||
keywords: ["time", "duration", "delivery_time", "delivery_duration", "hour", "minute"],
|
||||
unit: "분",
|
||||
color: "orange",
|
||||
icon: "⏱️",
|
||||
useAvg: true,
|
||||
aggregation: "avg",
|
||||
koreanLabel: "평균 배송시간"
|
||||
},
|
||||
// 수량/개수
|
||||
// 수량/개수 - 합계
|
||||
quantity: {
|
||||
keywords: ["quantity", "qty", "amount"],
|
||||
keywords: ["quantity", "qty", "count", "number"],
|
||||
unit: "개",
|
||||
color: "purple",
|
||||
icon: "📦",
|
||||
aggregation: "sum",
|
||||
koreanLabel: "총 수량"
|
||||
},
|
||||
// 금액/가격
|
||||
price: {
|
||||
keywords: ["price", "cost", "fee"],
|
||||
// 금액/가격 - 합계
|
||||
amount: {
|
||||
keywords: ["amount", "price", "cost", "fee", "total", "sum"],
|
||||
unit: "원",
|
||||
color: "yellow",
|
||||
icon: "💰",
|
||||
aggregation: "sum",
|
||||
koreanLabel: "총 금액"
|
||||
},
|
||||
// 비율/퍼센트
|
||||
// 비율/퍼센트 - 평균
|
||||
rate: {
|
||||
keywords: ["rate", "ratio", "percent", "efficiency"],
|
||||
keywords: ["rate", "ratio", "percent", "efficiency", "%"],
|
||||
unit: "%",
|
||||
color: "cyan",
|
||||
icon: "📈",
|
||||
useAvg: true,
|
||||
aggregation: "avg",
|
||||
koreanLabel: "평균 비율"
|
||||
},
|
||||
// 처리량
|
||||
// 처리량 - 합계
|
||||
throughput: {
|
||||
keywords: ["throughput", "output", "production"],
|
||||
keywords: ["throughput", "output", "production", "volume"],
|
||||
unit: "개",
|
||||
color: "pink",
|
||||
icon: "⚡",
|
||||
aggregation: "sum",
|
||||
koreanLabel: "총 처리량"
|
||||
},
|
||||
// 재고
|
||||
// 재고 - 평균 (현재 재고는 평균이 의미있음)
|
||||
stock: {
|
||||
keywords: ["stock", "inventory"],
|
||||
unit: "개",
|
||||
color: "teal",
|
||||
icon: "📦",
|
||||
koreanLabel: "재고 수량"
|
||||
aggregation: "avg",
|
||||
koreanLabel: "평균 재고"
|
||||
},
|
||||
// 설비/장비
|
||||
// 설비/장비 - 평균
|
||||
equipment: {
|
||||
keywords: ["equipment", "facility", "machine"],
|
||||
unit: "대",
|
||||
color: "gray",
|
||||
icon: "🏭",
|
||||
koreanLabel: "가동 설비"
|
||||
aggregation: "avg",
|
||||
koreanLabel: "평균 가동 설비"
|
||||
},
|
||||
// 점수/평점 - 평균
|
||||
score: {
|
||||
keywords: ["score", "rating", "point", "grade"],
|
||||
unit: "점",
|
||||
color: "indigo",
|
||||
icon: "⭐",
|
||||
aggregation: "avg",
|
||||
koreanLabel: "평균 점수"
|
||||
},
|
||||
// 온도 - 평균
|
||||
temperature: {
|
||||
keywords: ["temp", "temperature", "degree"],
|
||||
unit: "°C",
|
||||
color: "red",
|
||||
icon: "🌡️",
|
||||
aggregation: "avg",
|
||||
koreanLabel: "평균 온도"
|
||||
},
|
||||
// 속도 - 평균
|
||||
speed: {
|
||||
keywords: ["speed", "velocity"],
|
||||
unit: "km/h",
|
||||
color: "blue",
|
||||
icon: "🚀",
|
||||
aggregation: "avg",
|
||||
koreanLabel: "평균 속도"
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -223,40 +352,102 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
let unit = "";
|
||||
let color = "gray";
|
||||
let icon = "📊";
|
||||
let useAvg = false;
|
||||
let aggregation: "sum" | "avg" | "max" | "min" = "sum"; // 기본값은 합계
|
||||
let matchedConfig = null;
|
||||
|
||||
// 키워드 매칭으로 라벨, 단위, 색상 자동 설정
|
||||
// 키워드 매칭으로 라벨, 단위, 색상, 집계방식 자동 설정
|
||||
for (const [configKey, config] of Object.entries(columnConfig)) {
|
||||
if (config.keywords.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) {
|
||||
unit = config.unit;
|
||||
color = config.color;
|
||||
icon = config.icon;
|
||||
useAvg = config.useAvg || false;
|
||||
aggregation = config.aggregation;
|
||||
matchedConfig = config;
|
||||
|
||||
// 한글 라벨 사용 또는 자동 변환
|
||||
label = config.koreanLabel || key
|
||||
.replace(/_/g, " ")
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.trim();
|
||||
if (config.koreanLabel) {
|
||||
label = config.koreanLabel;
|
||||
} else {
|
||||
// 집계 방식에 따라 접두어 추가
|
||||
const prefix = aggregation === "avg" ? "평균 " : aggregation === "sum" ? "총 " : "";
|
||||
label = prefix + key
|
||||
.replace(/_/g, " ")
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.trim();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 매칭되지 않은 경우 기본 라벨 생성
|
||||
if (!matchedConfig) {
|
||||
label = key
|
||||
.replace(/_/g, " ")
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.trim()
|
||||
.split(" ")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
// 컬럼명 번역 시도
|
||||
const translatedName = columnNameTranslation[key.toLowerCase()];
|
||||
|
||||
if (translatedName) {
|
||||
// 번역된 이름이 있으면 사용
|
||||
label = translatedName;
|
||||
} else {
|
||||
// 컬럼명에 avg, average, mean이 포함되면 평균으로 간주
|
||||
if (key.toLowerCase().includes("avg") ||
|
||||
key.toLowerCase().includes("average") ||
|
||||
key.toLowerCase().includes("mean")) {
|
||||
aggregation = "avg";
|
||||
|
||||
// 언더스코어로 분리된 각 단어 번역 시도
|
||||
const cleanKey = key.replace(/avg|average|mean/gi, "").replace(/_/g, " ").trim();
|
||||
const words = cleanKey.split(/[_\s]+/);
|
||||
const translatedWords = words.map(word =>
|
||||
columnNameTranslation[word.toLowerCase()] || word
|
||||
);
|
||||
label = "평균 " + translatedWords.join(" ");
|
||||
}
|
||||
// total, sum이 포함되면 합계로 간주
|
||||
else if (key.toLowerCase().includes("total") || key.toLowerCase().includes("sum")) {
|
||||
aggregation = "sum";
|
||||
|
||||
// 언더스코어로 분리된 각 단어 번역 시도
|
||||
const cleanKey = key.replace(/total|sum/gi, "").replace(/_/g, " ").trim();
|
||||
const words = cleanKey.split(/[_\s]+/);
|
||||
const translatedWords = words.map(word =>
|
||||
columnNameTranslation[word.toLowerCase()] || word
|
||||
);
|
||||
label = "총 " + translatedWords.join(" ");
|
||||
}
|
||||
// 기본값 - 각 단어별로 번역 시도
|
||||
else {
|
||||
const words = key.split(/[_\s]+/);
|
||||
const translatedWords = words.map(word => {
|
||||
const translated = columnNameTranslation[word.toLowerCase()];
|
||||
if (translated) {
|
||||
return translated;
|
||||
}
|
||||
// 번역이 없으면 첫 글자 대문자로
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
});
|
||||
label = translatedWords.join(" ");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 합계 또는 평균 선택
|
||||
const value = useAvg ? stats.avg : stats.sum;
|
||||
// 집계 방식에 따라 값 선택
|
||||
let value: number;
|
||||
switch (aggregation) {
|
||||
case "avg":
|
||||
value = stats.avg;
|
||||
break;
|
||||
case "sum":
|
||||
value = stats.sum;
|
||||
break;
|
||||
case "max":
|
||||
value = Math.max(...data.map((item: any) => parseFloat(item[key]) || 0));
|
||||
break;
|
||||
case "min":
|
||||
value = Math.min(...data.map((item: any) => parseFloat(item[key]) || 0));
|
||||
break;
|
||||
default:
|
||||
value = stats.sum;
|
||||
}
|
||||
|
||||
statsItems.push({
|
||||
label,
|
||||
|
|
@ -277,11 +468,20 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
approved: "승인률",
|
||||
};
|
||||
|
||||
const addedBooleanLabels = new Set<string>(); // 중복 방지
|
||||
|
||||
Object.keys(firstRow).forEach((key) => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
const matchedKey = Object.keys(booleanMapping).find((k) => lowerKey.includes(k));
|
||||
|
||||
if (matchedKey) {
|
||||
const label = booleanMapping[matchedKey];
|
||||
|
||||
// 이미 추가된 라벨이면 스킵
|
||||
if (addedBooleanLabels.has(label)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validItems = data.filter((item: any) => item[key] !== null && item[key] !== undefined);
|
||||
|
||||
if (validItems.length > 0) {
|
||||
|
|
@ -293,34 +493,95 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
const rate = (trueCount / validItems.length) * 100;
|
||||
|
||||
statsItems.push({
|
||||
label: booleanMapping[matchedKey],
|
||||
label,
|
||||
value: rate,
|
||||
unit: "%",
|
||||
color: "purple",
|
||||
icon: "✅",
|
||||
});
|
||||
|
||||
addedBooleanLabels.add(label);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// console.log("📊 생성된 통계 항목:", statsItems.map(s => s.label));
|
||||
setAllStats(statsItems);
|
||||
|
||||
// 초기에는 모든 통계 표시 (최대 6개)
|
||||
if (selectedStats.length === 0) {
|
||||
setStats(statsItems.slice(0, 6));
|
||||
setSelectedStats(statsItems.slice(0, 6).map((s) => s.label));
|
||||
// 초기화가 아직 안됐으면 localStorage에서 설정 불러오기
|
||||
if (!isInitializedRef.current) {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
// console.log("💾 저장된 설정:", saved);
|
||||
|
||||
if (saved) {
|
||||
try {
|
||||
const savedLabels = JSON.parse(saved);
|
||||
// console.log("✅ 저장된 라벨:", savedLabels);
|
||||
|
||||
const filtered = statsItems.filter((s) => savedLabels.includes(s.label));
|
||||
// console.log("🔍 필터링된 통계:", filtered.map(s => s.label));
|
||||
// console.log(`📊 일치율: ${filtered.length}/${savedLabels.length} (${Math.round(filtered.length / savedLabels.length * 100)}%)`);
|
||||
|
||||
// 50% 이상 일치하면 저장된 설정 사용
|
||||
const matchRate = filtered.length / savedLabels.length;
|
||||
if (matchRate >= 0.5 && filtered.length > 0) {
|
||||
setStats(filtered);
|
||||
// 실제 표시되는 라벨로 업데이트
|
||||
const actualLabels = filtered.map(s => s.label);
|
||||
setSelectedStats(actualLabels);
|
||||
selectedStatsRef.current = actualLabels;
|
||||
// localStorage도 업데이트하여 다음에는 정확히 일치하도록
|
||||
localStorage.setItem(storageKey, JSON.stringify(actualLabels));
|
||||
// console.log(`✅ ${filtered.length}개 통계 표시 (저장된 설정 기반)`);
|
||||
} else {
|
||||
// 일치율이 낮으면 처음 6개 표시하고 localStorage 업데이트
|
||||
// console.warn(`⚠️ 일치율 ${Math.round(matchRate * 100)}% - 기본값 사용`);
|
||||
const defaultLabels = statsItems.slice(0, 6).map((s) => s.label);
|
||||
setStats(statsItems.slice(0, 6));
|
||||
setSelectedStats(defaultLabels);
|
||||
selectedStatsRef.current = defaultLabels;
|
||||
localStorage.setItem(storageKey, JSON.stringify(defaultLabels));
|
||||
}
|
||||
} catch (e) {
|
||||
// console.error("❌ 설정 파싱 실패:", e);
|
||||
const defaultLabels = statsItems.slice(0, 6).map((s) => s.label);
|
||||
setStats(statsItems.slice(0, 6));
|
||||
setSelectedStats(defaultLabels);
|
||||
selectedStatsRef.current = defaultLabels;
|
||||
}
|
||||
} else {
|
||||
// 저장된 설정이 없으면 처음 6개 표시
|
||||
// console.log("🆕 저장된 설정 없음. 기본값 사용");
|
||||
const defaultLabels = statsItems.slice(0, 6).map((s) => s.label);
|
||||
setStats(statsItems.slice(0, 6));
|
||||
setSelectedStats(defaultLabels);
|
||||
selectedStatsRef.current = defaultLabels;
|
||||
}
|
||||
isInitializedRef.current = true;
|
||||
} else {
|
||||
// 선택된 통계만 표시
|
||||
const filtered = statsItems.filter((s) => selectedStats.includes(s.label));
|
||||
setStats(filtered);
|
||||
// 이미 초기화됐으면 현재 선택된 통계 유지
|
||||
const currentSelected = selectedStatsRef.current;
|
||||
// console.log("🔄 현재 선택된 통계:", currentSelected);
|
||||
|
||||
if (currentSelected.length > 0) {
|
||||
const filtered = statsItems.filter((s) => currentSelected.includes(s.label));
|
||||
// console.log("🔍 필터링 결과:", filtered.map(s => s.label));
|
||||
|
||||
if (filtered.length > 0) {
|
||||
setStats(filtered);
|
||||
} else {
|
||||
// console.warn("⚠️ 선택된 항목과 일치하는 통계가 없음");
|
||||
setStats(statsItems.slice(0, 6));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("통계 로드 실패:", err);
|
||||
// console.error("통계 로드 실패:", err);
|
||||
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [element?.dataSource, storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
|
@ -391,23 +652,35 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
|
||||
const handleToggleStat = (label: string) => {
|
||||
setSelectedStats((prev) => {
|
||||
if (prev.includes(label)) {
|
||||
return prev.filter((l) => l !== label);
|
||||
} else {
|
||||
return [...prev, label];
|
||||
}
|
||||
const newStats = prev.includes(label)
|
||||
? prev.filter((l) => l !== label)
|
||||
: [...prev, label];
|
||||
// console.log("🔘 토글:", label, "→", newStats.length + "개 선택");
|
||||
return newStats;
|
||||
});
|
||||
};
|
||||
|
||||
const handleApplySettings = () => {
|
||||
// console.log("💾 설정 적용:", selectedStats);
|
||||
// console.log("📊 전체 통계:", allStats.map(s => s.label));
|
||||
|
||||
const filtered = allStats.filter((s) => selectedStats.includes(s.label));
|
||||
// console.log("✅ 필터링 결과:", filtered.map(s => s.label));
|
||||
|
||||
setStats(filtered);
|
||||
selectedStatsRef.current = selectedStats; // ref도 업데이트
|
||||
setShowSettings(false);
|
||||
|
||||
// localStorage에 설정 저장
|
||||
localStorage.setItem(storageKey, JSON.stringify(selectedStats));
|
||||
// console.log("💾 localStorage 저장 완료:", selectedStats.length + "개");
|
||||
};
|
||||
|
||||
// 렌더링 시 상태 로그
|
||||
// console.log("🎨 렌더링 - stats:", stats.map(s => s.label));
|
||||
// console.log("🎨 렌더링 - selectedStats:", selectedStats);
|
||||
// console.log("🎨 렌더링 - allStats:", allStats.map(s => s.label));
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-col bg-white">
|
||||
{/* 헤더 영역 */}
|
||||
|
|
@ -418,7 +691,13 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
<span className="text-xs text-gray-500">({stats.length}개 표시 중)</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
onClick={() => {
|
||||
// 설정 모달 열 때 현재 표시 중인 통계로 동기화
|
||||
const currentLabels = stats.map(s => s.label);
|
||||
// console.log("⚙️ 설정 모달 열기 - 현재 표시 중:", currentLabels);
|
||||
setSelectedStats(currentLabels);
|
||||
setShowSettings(true);
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100"
|
||||
title="표시할 통계 선택"
|
||||
>
|
||||
|
|
@ -435,7 +714,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
return (
|
||||
<div key={index} className={`rounded-lg border ${colors.bg} p-4 text-center`}>
|
||||
<div className="text-sm text-gray-600">
|
||||
{stat.icon} {stat.label}
|
||||
{stat.label}
|
||||
</div>
|
||||
<div className={`mt-2 text-3xl font-bold ${colors.text}`}>
|
||||
{stat.value.toFixed(stat.unit === "%" || stat.unit === "분" ? 1 : 0).toLocaleString()}
|
||||
|
|
@ -463,24 +742,32 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{allStats.map((stat, index) => (
|
||||
<label
|
||||
key={index}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-lg border p-3 hover:bg-gray-50"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedStats.includes(stat.label)}
|
||||
onChange={() => handleToggleStat(stat.label)}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
<span className="text-xl">{stat.icon}</span>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{stat.label}</div>
|
||||
<div className="text-sm text-gray-500">단위: {stat.unit}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
{allStats.map((stat, index) => {
|
||||
const isChecked = selectedStats.includes(stat.label);
|
||||
return (
|
||||
<label
|
||||
key={index}
|
||||
className={`flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors ${
|
||||
isChecked ? "border-blue-500 bg-blue-50" : "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => {
|
||||
// console.log("📋 체크박스 클릭:", stat.label, "현재:", isChecked);
|
||||
handleToggleStat(stat.label);
|
||||
}}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{stat.label}</div>
|
||||
<div className="text-sm text-gray-500">단위: {stat.unit}</div>
|
||||
</div>
|
||||
{isChecked && <span className="text-blue-600">✓</span>}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-2">
|
||||
|
|
@ -491,7 +778,10 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
적용 ({selectedStats.length}개 선택)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSettings(false)}
|
||||
onClick={() => {
|
||||
// console.log("❌ 설정 취소");
|
||||
setShowSettings(false);
|
||||
}}
|
||||
className="rounded-lg border px-4 py-2 hover:bg-gray-50"
|
||||
>
|
||||
취소
|
||||
|
|
|
|||
|
|
@ -89,34 +89,6 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) {
|
|||
// 필터링된 알림
|
||||
const filteredAlerts = filter === "all" ? alerts : alerts.filter((alert) => alert.type === filter);
|
||||
|
||||
// 심각도별 색상
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case "high":
|
||||
return "border-red-500";
|
||||
case "medium":
|
||||
return "border-yellow-500";
|
||||
case "low":
|
||||
return "border-blue-500";
|
||||
default:
|
||||
return "border-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
// 심각도별 배지 색상
|
||||
const getSeverityBadge = (severity: string) => {
|
||||
switch (severity) {
|
||||
case "high":
|
||||
return "bg-red-100 text-red-700";
|
||||
case "medium":
|
||||
return "bg-yellow-100 text-yellow-700";
|
||||
case "low":
|
||||
return "bg-blue-100 text-blue-700";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700";
|
||||
}
|
||||
};
|
||||
|
||||
// 알림 타입별 아이콘
|
||||
const getAlertIcon = (type: AlertType) => {
|
||||
switch (type) {
|
||||
|
|
@ -163,69 +135,75 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col gap-3 overflow-hidden bg-slate-50 p-3">
|
||||
<div className="flex h-full w-full flex-col gap-4 overflow-hidden bg-background p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between border-b pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600" />
|
||||
<h3 className="text-base font-semibold text-gray-900">{element?.customTitle || "리스크 / 알림"}</h3>
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
<h3 className="text-lg font-semibold">{element?.customTitle || "리스크 / 알림"}</h3>
|
||||
{stats.high > 0 && (
|
||||
<Badge className="bg-red-100 text-red-700 hover:bg-red-100">긴급 {stats.high}건</Badge>
|
||||
<Badge variant="destructive">긴급 {stats.high}건</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{lastUpdated && newAlertIds.size > 0 && (
|
||||
<Badge className="bg-blue-100 text-blue-700 text-xs animate-pulse">
|
||||
<Badge variant="secondary" className="animate-pulse">
|
||||
새 알림 {newAlertIds.size}건
|
||||
</Badge>
|
||||
)}
|
||||
{lastUpdated && (
|
||||
<span className="text-xs text-gray-500">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{lastUpdated.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={loadData} disabled={isRefreshing} className="h-8 px-2">
|
||||
<Button variant="ghost" size="sm" onClick={loadData} disabled={isRefreshing}>
|
||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Card
|
||||
className={`cursor-pointer border-l-4 border-red-500 p-2 transition-colors hover:bg-gray-50 ${filter === "accident" ? "bg-gray-100" : ""}`}
|
||||
className={`cursor-pointer p-3 transition-all hover:shadow-md ${
|
||||
filter === "accident" ? "bg-red-50" : ""
|
||||
}`}
|
||||
onClick={() => setFilter(filter === "accident" ? "all" : "accident")}
|
||||
>
|
||||
<div className="text-xs text-gray-600">교통사고</div>
|
||||
<div className="text-lg font-bold text-gray-900">{stats.accident}건</div>
|
||||
<div className="text-xs text-muted-foreground">교통사고</div>
|
||||
<div className="text-2xl font-bold text-red-600">{stats.accident}건</div>
|
||||
</Card>
|
||||
<Card
|
||||
className={`cursor-pointer border-l-4 border-blue-500 p-2 transition-colors hover:bg-gray-50 ${filter === "weather" ? "bg-gray-100" : ""}`}
|
||||
className={`cursor-pointer p-3 transition-all hover:shadow-md ${
|
||||
filter === "weather" ? "bg-blue-50" : ""
|
||||
}`}
|
||||
onClick={() => setFilter(filter === "weather" ? "all" : "weather")}
|
||||
>
|
||||
<div className="text-xs text-gray-600">날씨특보</div>
|
||||
<div className="text-lg font-bold text-gray-900">{stats.weather}건</div>
|
||||
<div className="text-xs text-muted-foreground">날씨특보</div>
|
||||
<div className="text-2xl font-bold text-blue-600">{stats.weather}건</div>
|
||||
</Card>
|
||||
<Card
|
||||
className={`cursor-pointer border-l-4 border-yellow-500 p-2 transition-colors hover:bg-gray-50 ${filter === "construction" ? "bg-gray-100" : ""}`}
|
||||
className={`cursor-pointer p-3 transition-all hover:shadow-md ${
|
||||
filter === "construction" ? "bg-yellow-50" : ""
|
||||
}`}
|
||||
onClick={() => setFilter(filter === "construction" ? "all" : "construction")}
|
||||
>
|
||||
<div className="text-xs text-gray-600">도로공사</div>
|
||||
<div className="text-lg font-bold text-gray-900">{stats.construction}건</div>
|
||||
<div className="text-xs text-muted-foreground">도로공사</div>
|
||||
<div className="text-2xl font-bold text-yellow-600">{stats.construction}건</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 필터 상태 표시 */}
|
||||
{filter !== "all" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Badge variant="outline">
|
||||
{getAlertTypeName(filter)} 필터 적용 중
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => setFilter("all")}
|
||||
className="h-6 px-2 text-xs text-gray-600"
|
||||
className="h-auto p-0 text-xs"
|
||||
>
|
||||
전체 보기
|
||||
</Button>
|
||||
|
|
@ -236,44 +214,44 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) {
|
|||
<div className="flex-1 space-y-2 overflow-y-auto">
|
||||
{filteredAlerts.length === 0 ? (
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-sm text-gray-500">알림이 없습니다</div>
|
||||
<div className="text-sm text-muted-foreground">알림이 없습니다</div>
|
||||
</Card>
|
||||
) : (
|
||||
filteredAlerts.map((alert) => (
|
||||
<Card
|
||||
key={alert.id}
|
||||
className={`border-l-4 p-3 transition-all duration-300 ${getSeverityColor(alert.severity)} ${
|
||||
newAlertIds.has(alert.id) ? 'bg-blue-50/30 ring-1 ring-blue-200' : ''
|
||||
className={`p-3 transition-all duration-300 ${
|
||||
newAlertIds.has(alert.id) ? 'bg-accent ring-1 ring-primary' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
{getAlertIcon(alert.type)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold text-gray-900">{alert.title}</h4>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="text-sm font-semibold">{alert.title}</h4>
|
||||
{newAlertIds.has(alert.id) && (
|
||||
<Badge className="bg-blue-100 text-blue-700 text-xs">
|
||||
<Badge variant="secondary">
|
||||
NEW
|
||||
</Badge>
|
||||
)}
|
||||
<Badge className={`text-xs ${getSeverityBadge(alert.severity)}`}>
|
||||
<Badge variant={alert.severity === "high" ? "destructive" : alert.severity === "medium" ? "default" : "secondary"}>
|
||||
{alert.severity === "high" ? "긴급" : alert.severity === "medium" ? "주의" : "정보"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs font-medium text-gray-700">{alert.location}</p>
|
||||
<p className="mt-1 text-xs text-gray-600">{alert.description}</p>
|
||||
<p className="mt-1 text-xs font-medium text-foreground">{alert.location}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{alert.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-right text-xs text-gray-500">{formatTime(alert.timestamp)}</div>
|
||||
<div className="mt-2 text-right text-xs text-muted-foreground">{formatTime(alert.timestamp)}</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 안내 메시지 */}
|
||||
<div className="border-t border-gray-200 pt-2 text-center text-xs text-gray-500">
|
||||
<div className="border-t pt-3 text-center text-xs text-muted-foreground">
|
||||
💡 1분마다 자동으로 업데이트됩니다
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue