대시보드관리디벨롭 #112

Merged
hjlee merged 1 commits from lhj into main 2025-10-20 17:43:05 +09:00
9 changed files with 447 additions and 171 deletions

View File

@ -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",

View File

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

View File

@ -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,

View File

@ -200,12 +200,13 @@ export function DashboardSidebar() {
subtype="todo"
onDragStart={handleDragStart}
/>
<DraggableItem
{/* 예약알림 위젯 - 필요시 주석 해제 */}
{/* <DraggableItem
title="예약 요청 알림"
type="widget"
subtype="booking-alert"
onDragStart={handleDragStart}
/>
/> */}
{/* 정비 일정 관리 위젯 제거 - 커스텀 목록 카드로 대체 가능 */}
<DraggableItem
title="문서 다운로드"

View File

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

View File

@ -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,

View File

@ -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} />;
// === 차량 관련 (추가 위젯) ===

View File

@ -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"
>

View File

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