2025-10-15 16:16:27 +09:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
|
|
|
|
import { DashboardElement } from "@/components/admin/dashboard/types";
|
|
|
|
|
|
|
|
|
|
|
|
interface CustomerIssuesWidgetProps {
|
|
|
|
|
|
element: DashboardElement;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface Issue {
|
|
|
|
|
|
id: string | number;
|
|
|
|
|
|
issue_type?: string;
|
|
|
|
|
|
issueType?: string;
|
|
|
|
|
|
customer_name?: string;
|
|
|
|
|
|
customerName?: string;
|
|
|
|
|
|
description?: string;
|
|
|
|
|
|
priority?: string;
|
|
|
|
|
|
created_at?: string;
|
|
|
|
|
|
createdAt?: string;
|
|
|
|
|
|
status?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 고객 클레임/이슈 위젯
|
|
|
|
|
|
* - 클레임/이슈 목록 표시
|
|
|
|
|
|
* - 우선순위별 배지 표시
|
|
|
|
|
|
*/
|
|
|
|
|
|
export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetProps) {
|
|
|
|
|
|
const [issues, setIssues] = useState<Issue[]>([]);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
const [filterPriority, setFilterPriority] = useState<string>("all");
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadData();
|
|
|
|
|
|
|
|
|
|
|
|
// 자동 새로고침 (30초마다)
|
|
|
|
|
|
const interval = setInterval(loadData, 30000);
|
|
|
|
|
|
return () => clearInterval(interval);
|
|
|
|
|
|
}, [element]);
|
|
|
|
|
|
|
|
|
|
|
|
const loadData = async () => {
|
|
|
|
|
|
if (!element?.dataSource?.query) {
|
|
|
|
|
|
setError("쿼리가 설정되지 않았습니다");
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
const token = localStorage.getItem("authToken");
|
|
|
|
|
|
const response = await fetch("/api/dashboards/execute-query", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
Authorization: `Bearer ${token}`,
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
query: element.dataSource.query,
|
|
|
|
|
|
connectionType: element.dataSource.connectionType || "current",
|
|
|
|
|
|
connectionId: element.dataSource.connectionId,
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) throw new Error("데이터 로딩 실패");
|
|
|
|
|
|
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (result.success && result.data?.rows) {
|
|
|
|
|
|
setIssues(result.data.rows);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getPriorityBadge = (priority: string) => {
|
|
|
|
|
|
const priorityLower = priority?.toLowerCase() || "";
|
|
|
|
|
|
|
|
|
|
|
|
if (priorityLower.includes("긴급") || priorityLower.includes("high") || priorityLower.includes("urgent")) {
|
|
|
|
|
|
return "bg-destructive text-destructive-foreground";
|
|
|
|
|
|
} else if (priorityLower.includes("보통") || priorityLower.includes("medium") || priorityLower.includes("normal")) {
|
|
|
|
|
|
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100";
|
|
|
|
|
|
} else if (priorityLower.includes("낮음") || priorityLower.includes("low")) {
|
|
|
|
|
|
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100";
|
|
|
|
|
|
}
|
|
|
|
|
|
return "bg-muted text-muted-foreground";
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getStatusBadge = (status: string) => {
|
|
|
|
|
|
const statusLower = status?.toLowerCase() || "";
|
|
|
|
|
|
|
|
|
|
|
|
if (statusLower.includes("처리중") || statusLower.includes("processing") || statusLower.includes("pending")) {
|
|
|
|
|
|
return "bg-primary text-primary-foreground";
|
|
|
|
|
|
} else if (statusLower.includes("완료") || statusLower.includes("resolved") || statusLower.includes("closed")) {
|
|
|
|
|
|
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100";
|
|
|
|
|
|
}
|
|
|
|
|
|
return "bg-muted text-muted-foreground";
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const filteredIssues = filterPriority === "all"
|
|
|
|
|
|
? issues
|
|
|
|
|
|
: issues.filter((issue) => {
|
|
|
|
|
|
const priority = (issue.priority || "").toLowerCase();
|
|
|
|
|
|
return priority.includes(filterPriority);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex h-full items-center justify-center">
|
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
|
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
|
|
|
|
|
<p className="mt-2 text-sm text-muted-foreground">데이터 로딩 중...</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex h-full items-center justify-center">
|
|
|
|
|
|
<div className="text-center text-destructive">
|
|
|
|
|
|
<p className="text-sm">⚠️ {error}</p>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={loadData}
|
|
|
|
|
|
className="mt-2 rounded-md bg-destructive/10 px-3 py-1 text-xs hover:bg-destructive/20"
|
|
|
|
|
|
>
|
|
|
|
|
|
다시 시도
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!element?.dataSource?.query) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex h-full items-center justify-center">
|
|
|
|
|
|
<div className="text-center text-muted-foreground">
|
|
|
|
|
|
<p className="text-sm">⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex h-full flex-col overflow-hidden bg-background p-4">
|
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
|
<div className="mb-4 flex items-center justify-between">
|
2025-10-17 14:52:08 +09:00
|
|
|
|
<h3 className="text-lg font-semibold text-foreground">고객 클레임/이슈</h3>
|
2025-10-15 16:16:27 +09:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={loadData}
|
|
|
|
|
|
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
|
|
|
|
|
title="새로고침"
|
|
|
|
|
|
>
|
|
|
|
|
|
🔄
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 필터 버튼 */}
|
|
|
|
|
|
<div className="mb-3 flex gap-2">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setFilterPriority("all")}
|
|
|
|
|
|
className={`rounded-md px-3 py-1 text-xs transition-colors ${
|
|
|
|
|
|
filterPriority === "all"
|
|
|
|
|
|
? "bg-primary text-primary-foreground"
|
|
|
|
|
|
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
전체
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setFilterPriority("긴급")}
|
|
|
|
|
|
className={`rounded-md px-3 py-1 text-xs transition-colors ${
|
|
|
|
|
|
filterPriority === "긴급"
|
|
|
|
|
|
? "bg-destructive text-destructive-foreground"
|
|
|
|
|
|
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
긴급
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setFilterPriority("보통")}
|
|
|
|
|
|
className={`rounded-md px-3 py-1 text-xs transition-colors ${
|
|
|
|
|
|
filterPriority === "보통"
|
|
|
|
|
|
? "bg-yellow-100 text-yellow-800"
|
|
|
|
|
|
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
보통
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setFilterPriority("낮음")}
|
|
|
|
|
|
className={`rounded-md px-3 py-1 text-xs transition-colors ${
|
|
|
|
|
|
filterPriority === "낮음"
|
|
|
|
|
|
? "bg-green-100 text-green-800"
|
|
|
|
|
|
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
낮음
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 총 건수 */}
|
|
|
|
|
|
<div className="mb-3 text-sm text-muted-foreground">
|
|
|
|
|
|
총 <span className="font-semibold text-foreground">{filteredIssues.length}</span>건
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 이슈 리스트 */}
|
|
|
|
|
|
<div className="flex-1 space-y-2 overflow-auto">
|
|
|
|
|
|
{filteredIssues.length === 0 ? (
|
|
|
|
|
|
<div className="flex h-full items-center justify-center text-center text-muted-foreground">
|
|
|
|
|
|
<p>이슈가 없습니다</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
filteredIssues.map((issue, index) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={issue.id || index}
|
|
|
|
|
|
className="rounded-lg border border-border bg-card p-3 transition-all hover:shadow-md"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="mb-2 flex items-start justify-between">
|
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
|
<div className="mb-1 flex items-center gap-2">
|
|
|
|
|
|
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${getPriorityBadge(issue.priority || "")}`}>
|
|
|
|
|
|
{issue.priority || "보통"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${getStatusBadge(issue.status || "")}`}>
|
|
|
|
|
|
{issue.status || "처리중"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="text-sm font-medium text-foreground">
|
|
|
|
|
|
{issue.issue_type || issue.issueType || "기타"}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<p className="mb-2 text-xs text-muted-foreground">
|
|
|
|
|
|
고객: {issue.customer_name || issue.customerName || "-"}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<p className="text-xs text-muted-foreground line-clamp-2">
|
|
|
|
|
|
{issue.description || "설명 없음"}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
{(issue.created_at || issue.createdAt) && (
|
|
|
|
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
|
|
|
|
{new Date(issue.created_at || issue.createdAt || "").toLocaleDateString("ko-KR")}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|