대시보드 수정정

This commit is contained in:
2025-12-15 19:21:40 +09:00
parent a7d349391c
commit a0574fda6c
11 changed files with 594 additions and 90 deletions
+317
View File
@@ -73,12 +73,32 @@
padding: 0 !important;
}
.card_paddingnone {
padding: 0 !important;
}
.card_nomargin {
width: 100%;
margin: 0 !important;
padding: 0 !important;
}
.card_title {
padding: 1rem;
margin-bottom: 1rem;
}
.card_title .leftArea {
display: flex;
align-items: center;
}
.card_title h4 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.topline {
border-top: 1px solid #ccc;
}
@@ -127,3 +147,300 @@
border-bottom-color: #3f4042;
}
}
/* 자료현황 스타일 */
.mid_info_area {
padding: 10px 5px;
height: 220px;
position: relative;
margin-top: 0;
}
.mi_list_swiper {
height: 100%;
width: 100%;
}
.mi_list {
height: 100%;
}
.ml_box {
position: relative;
width: 90%;
max-width: 90%;
height: 150px;
padding: 12px;
margin: 0 8px;
display: flex;
border-radius: 12px;
background: #f8f9fa;
border: 1px solid #e9ecef;
box-sizing: border-box;
transition: all 0.3s ease;
}
.ml_box:hover {
background: #e3f2fd;
border-color: #2196f3;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.mb_section {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
}
.tit {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.tit i {
font-size: 20px;
margin-right: 10px;
color: #2196f3;
}
.tt {
font-size: 16px;
font-weight: 600;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.info_row {
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
.info_item {
text-align: center;
width: 100%;
}
.info_label {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.info_value {
font-size: 18px;
font-weight: bold;
color: #2196f3;
line-height: 1;
}
/* Swiper 네비게이션 스타일 */
.mi_list_swiper .swiper-button-next,
.mi_list_swiper .swiper-button-prev {
color: #2196f3;
width: 30px;
height: 30px;
margin-top: -15px;
}
.mi_list_swiper .swiper-button-next:after,
.mi_list_swiper .swiper-button-prev:after {
font-size: 16px;
}
.mi_list_swiper .swiper-pagination {
bottom: 5px;
}
.mi_list_swiper .swiper-pagination-bullet {
background: #2196f3;
opacity: 0.3;
}
.mi_list_swiper .swiper-pagination-bullet-active {
opacity: 1;
}
/* 특별시 스타일 */
.ml_box.special-city {
background: linear-gradient(135deg, #ff6b6b, #ff8e8e);
border-color: #ff4757;
color: white;
}
.ml_box.special-city .tt {
color: white;
font-weight: 700;
}
.ml_box.special-city .info_label {
color: rgba(255, 255, 255, 0.8);
}
.ml_box.special-city .info_value {
color: white;
}
.ml_box.special-city:hover {
background: linear-gradient(135deg, #ff5252, #ff7979);
transform: translateY(-3px);
box-shadow: 0 6px 12px rgba(255, 107, 107, 0.3);
}
/* 광역시 스타일 */
.ml_box.metropolitan-city {
background: linear-gradient(135deg, #4facfe, #00f2fe);
border-color: #2196f3;
color: white;
}
.ml_box.metropolitan-city .tt {
color: white;
font-weight: 600;
}
.ml_box.metropolitan-city .info_label {
color: rgba(255, 255, 255, 0.8);
}
.ml_box.metropolitan-city .info_value {
color: white;
}
.ml_box.metropolitan-city:hover {
background: linear-gradient(135deg, #2196f3, #21cbf3);
transform: translateY(-3px);
box-shadow: 0 6px 12px rgba(33, 150, 243, 0.3);
}
/* 광역도 스타일 */
.ml_box.province {
background: linear-gradient(135deg, #a8edea, #fed6e3);
border-color: #00bcd4;
color: #333;
}
.ml_box.province .tt {
color: #333;
font-weight: 600;
}
.ml_box.province .info_label {
color: #666;
}
.ml_box.province .info_value {
color: #00bcd4;
}
.ml_box.province:hover {
background: linear-gradient(135deg, #00bcd4, #26c6da);
color: white;
transform: translateY(-3px);
box-shadow: 0 6px 12px rgba(0, 188, 212, 0.3);
}
.ml_box.province:hover .tt,
.ml_box.province:hover .info_label,
.ml_box.province:hover .info_value {
color: white;
}
/* 네이버 스타일 로그인 박스 */
.login_info_box {
padding: 20px;
background: #fff;
border: 1px solid #e1e5e9;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.login_header {
display: flex;
align-items: center;
margin-bottom: 10px;
padding-bottom: 5px;
border-bottom: 1px solid #f0f0f0;
width: 100%;
}
.login_avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
margin-right: 12px;
border: 2px solid #e1e5e9;
}
.login_avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.login_user_info {
flex: 1;
}
.user_name {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 2px;
}
.user_status {
font-size: 12px;
color: #03c75a;
font-weight: 500;
}
.login_actions {
width: 100%;
display: flex;
gap: 8px;
}
.login_btn {
flex: 1;
height: 40px;
background: #03c75a;
color: #fff;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.login_btn:hover {
background: #02b351;
transform: translateY(-1px);
}
.signup_btn {
flex: 1;
height: 40px;
background: #fff;
color: #03c75a;
border: 1px solid #03c75a;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.signup_btn:hover {
background: #03c75a;
color: #fff;
transform: translateY(-1px);
}
@@ -0,0 +1,48 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageableList } from "@/shared/model";
export interface DataStatus {
ctpvgnsvycnt: number;
filecnt: number;
ctpvnm: string;
}
export const getDataStatusList = async (): Promise<PageableList<DataStatus>> => {
const response = await client.get<DataStatus[]>(
`${ENDPOINT.DC_SERVICE.DATA_STATUS}`,
);
const data = response.data;
console.log("getDataStatusList data:", data);
return {
content: data as DataStatus[],
pageable: {
sort: {
sorted: false,
unsorted: true,
empty: true,
},
offset: 0,
pageNumber: 0,
pageSize: data.length as number,
paged: false,
unpaged: true,
},
sort: {
sorted: false,
unsorted: true,
empty: true,
},
totalPages: 1,
totalElements: data.length,
number: 0,
numberOfElements: data.length,
size: data.length,
first: true,
last: true,
empty: data.length === 0,
};
};
@@ -22,7 +22,7 @@ export const getSupportList = async (): Promise<PageableList<Support>> => {
console.log("getSupportList data:", data);
return {
content: data,
content: data as Support[],
pageable: {
sort: {
sorted: false,
@@ -31,7 +31,7 @@ export const getSupportList = async (): Promise<PageableList<Support>> => {
},
offset: 0,
pageNumber: 0,
pageSize: data.length,
pageSize: data.length as number,
paged: false,
unpaged: true,
},
@@ -1,5 +1,5 @@
import type { ColumnDef } from "@tanstack/react-table";
import type { Support } from "../api/support";
import type { Support } from "../api/support-service";
const typeLabelMap: Record<string, string> = {
waterQuality: "수질",
@@ -4,25 +4,6 @@ import { Badge } from "@common/components";
import type { MyPost, NoticePost } from "@/entities/post";
const noticePostTableColumns: ColumnDef<NoticePost>[] = [
{
header: "번호",
cell: ({ row, table }) => {
if (row.original.upndFixYn === "Y") {
return (
<Badge
color="orange"
className="flex size-8 items-center justify-center"
>
<i className="diveicon di-volume-up-fill" />
</Badge>
);
}
const totalRows = table.getRowCount();
const currentPage = table.getState().pagination.pageIndex;
const pageSize = table.getState().pagination.pageSize;
return totalRows - (currentPage * pageSize + row.index);
},
},
{
accessorKey: "pstTtl",
header: "제목",
@@ -43,15 +24,6 @@ const noticePostTableColumns: ColumnDef<NoticePost>[] = [
];
const myPostTableColumns: ColumnDef<MyPost>[] = [
{
header: "번호",
cell: ({ row, table }) => {
const totalRows = table.getRowCount();
const currentPage = table.getState().pagination.pageIndex;
const pageSize = table.getState().pagination.pageSize;
return totalRows - (currentPage * pageSize + row.index);
},
},
{
accessorKey: "pstTtl",
header: "제목",
@@ -3,7 +3,7 @@
import { useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { getImageUrlByAtflCd, getBannerList, type Banner } from "../api/banner";
import { getImageUrlByAtflCd, getBannerList, type Banner } from "../api/banner-service";
import { getCookie } from "@/shared/lib";
import {
@@ -54,7 +54,7 @@ const BannerList: React.FC = () => {
return (
<div
className="relative h-[280px] w-full cursor-pointer overflow-hidden transition-opacity hover:opacity-80"
className="relative h-[210px] w-full cursor-pointer overflow-hidden transition-opacity hover:opacity-80"
onClick={handleClick}
>
<div
@@ -147,7 +147,7 @@ const BannerList: React.FC = () => {
}
return (
<div className="card card-border">
<div className="card card-border" style={{ height: "230px" }}>
<Carousel
plugins={[plugin.current]}
opts={{ loop: true }}
@@ -168,7 +168,7 @@ const BannerList: React.FC = () => {
))
) : (
<CarouselItem className="basis-full pl-0">
<div className="relative flex h-[280px] w-full items-center justify-center bg-accent">
<div className="relative flex h-[210px] w-full items-center justify-center bg-accent">
<div className="text-sm text-muted-foreground">
배너가 없습니다
</div>
@@ -26,6 +26,7 @@ import ReuseStatus from "./reuse-status";
import NoticeList from "./notice-list";
import BannerList from "./banner-list";
import SupportList from "./support-list";
import DataStatusList from "./datastatus-list";
// Chart.js 등록
ChartJS.register(
@@ -141,55 +142,67 @@ function DashboardPage() {
<KoreaBasinMap />
{/* 사용자 정보 카드 */}
<div className="card card-border col-span-1 h-80 p-10">
<h2 className="mb-6 text-2xl">사용자 정보</h2>
<div className="col-span-1 !p-0">
{error && <div className="mb-4 text-red-500">{error}</div>}
{userInfo ? (
<div className="mb-4">
<div>
<b>아이디:</b> {userInfo.userId}
<div className="card card_border login_info_box" style={{ height: "150px" }}>
<div className="login_header">
<div className="login_avatar">
<img src="/images/icon_loginID.png" alt="프로필" onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3E%3Ccircle cx='20' cy='20' r='18' fill='%23e1e5e9'/%3E%3Ctext x='20' y='26' font-size='20' text-anchor='middle' fill='%23999'%3E%3F%3C/text%3E%3C/svg%3E";
}} />
</div>
<div>
<b>이메일:</b> {userInfo.email || userInfo.userId}
</div>
<div>
<b>이름:</b> {userInfo.userName || "-"}
</div>
<div>
<b>2FA 활성화:</b>{" "}
{userInfo.twoFactorEnabled ? "예" : "아니오"}
<div className="login_user_info">
<div className="user_name">{userInfo.userName || "-"}</div>
<div className="user_status">온라인</div>
<div className="user_status">
<b>2FA 활성화:</b>{" "}
{userInfo.twoFactorEnabled ? "예" : "아니오"}
</div>
</div>
</div>
<div className="login_actions">
<button className="login_btn" onClick={handleLogout}>로그아웃</button>
<button className="signup_btn" onClick={handleChangePassword}>마이페이지</button>
</div>
</div>
) : (
!error && <div>사용자 정보를 불러오는 중...</div>
)}
<button className="btn mb-2 w-full" onClick={handleLogout}>
로그아웃
</button>
<button className="btn w-full" onClick={handleChangePassword}>
패스워드 변경
</button>
</div>
<div className="card card-border" style={{ height: "300px" }}>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">일정 알림</h3>
</div>
<div className="card paddingnone">
</div>
</div>
<div className="card card-border" style={{ height: "300px" }}>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">내 작성글</h3>
</div>
<div className="card paddingnone">
</div>
</div>
</div>
</div>
{/* 두번째 라인 */}
<div className="grid grid-cols-4 gap-1">
<div className="card card-border">
<div className="card card-border" style={{ height: "250px" }}>
<SupportList />
</div>
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">자료관 자료현황</h3>
</div>
<div className="h-70 p-4">
</div>
<div className="card card-border" style={{ height: "250px" }}>
<DataStatusList />
</div>
<div className="card card-border">
<div className="card card-border" style={{ height: "250px" }}>
<NoticeList />
</div>
{/* 배너 영역 */}
<div className="card card-border" style={{ height: "320px" }}>
<div className="card card-border" style={{ height: "250px" }}>
<BannerList />
</div>
</div>
@@ -0,0 +1,174 @@
"use client";
import { useRef, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { getDataStatusList, type DataStatus } from "../api/datastatus-service";
import type { PageableList } from "@/shared/model";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
Autoplay,
} from "@common/components";
// 지역 타입에 따른 아이콘과 클래스 결정
const getRegionInfo = (regionName: string) => {
if (regionName.includes("서울특별시")) {
return { icon: "🏛️", className: "special-city" };
}
if (
regionName.includes("광역시") ||
regionName.includes("부산") ||
regionName.includes("대구") ||
regionName.includes("인천") ||
regionName.includes("광주") ||
regionName.includes("대전") ||
regionName.includes("울산")
) {
return { icon: "🏭", className: "metropolitan-city" };
}
return { icon: "🌊", className: "province" };
};
const DataStatusList: React.FC = () => {
const plugin = useRef(
Autoplay({ delay: 3000, stopOnInteraction: true })
);
const {
data: dataStatusResponse,
error: dataStatusError,
isLoading: isLoadingDataStatus,
} = useQuery<PageableList<DataStatus>>({
queryKey: [ENDPOINT.DC_SERVICE.DATA_STATUS],
queryFn: () => getDataStatusList(),
enabled: true,
retry: false,
staleTime: 5 * 60 * 1000, // 5분간 fresh 상태 유지
refetchOnWindowFocus: false, // 윈도우 포커스 시 리페칭 방지
refetchOnMount: false, // 마운트 시 리페칭 방지 (캐시된 데이터 사용)
});
const dataStatusList = dataStatusResponse?.content || [];
// 에러 상태 판별 헬퍼 함수
const getErrorStatus = (e: unknown): number | undefined => {
if (!e || typeof e !== "object") return undefined;
const maybe = e as { status?: number; response?: { status?: number } };
return maybe.status ?? maybe.response?.status;
};
// 401 에러 처리
if (dataStatusError) {
const is401Error =
(dataStatusError instanceof Error && dataStatusError.message.includes("401")) ||
getErrorStatus(dataStatusError) === 401;
if (is401Error) {
console.error("[DataStatusList] 401 인증 오류:", dataStatusError);
return (
<div className="card card-border">
<div className="relative flex h-[280px] w-full items-center justify-center bg-accent">
<div className="text-sm text-red-500">
인증이 필요합니다. 로그인해주세요.
</div>
</div>
</div>
);
}
console.error("[DataStatusList] 데이터 조회 오류:", dataStatusError);
return (
<div className="card card-border">
<div className="relative flex h-[240px] w-full items-center justify-center bg-accent">
<div className="text-sm text-muted-foreground">
데이터를 불러올 수 없습니다
</div>
</div>
</div>
);
}
// 로딩 중
if (isLoadingDataStatus) {
return (
<div className="card card-border">
<div className="relative flex h-[200px] w-full items-center justify-center bg-accent" >
<div className="text-sm text-muted-foreground">데이터 로딩 중...</div>
</div>
</div>
);
}
return (
<>
<div className="card_title bottomline">
<div className="leftArea">
<h4>자료현황</h4>
</div>
</div>
<div className="card card_paddingnone">
<div className="mid_info_area" style={{ width: "100%", padding: "5px" }}>
<Carousel
plugins={[plugin.current]}
opts={{ loop: true }}
className="relative mi_list_swiper"
>
<CarouselContent className="-ml-4 mi_list">
{dataStatusList.length > 0 ? (
dataStatusList.map((dataStatus) => {
const regionInfo = getRegionInfo(dataStatus.ctpvnm);
return (
<CarouselItem
key={`dataStatus-${dataStatus.ctpvnm}`}
className={`basis-1/2 pl-4 ml_box ${regionInfo.className}`}
style={{ width: "200px" }}
>
<div className="mb_section">
<div className="tit">
<i className="cloud-arrow-down-fill color_3">
{regionInfo.icon}
</i>
<span className="tt">{dataStatus.ctpvnm}</span>
</div>
<div className="info_row">
<div className="info_item">
<div className="info_label">기본계획</div>
<div className="info_value">
{dataStatus.ctpvgnsvycnt}
</div>
</div>
<div className="info_item">
<div className="info_label">파일수</div>
<div className="info_value">{dataStatus.filecnt}</div>
</div>
</div>
</div>
</CarouselItem>
);
})
) : (
<CarouselItem className="basis-full pl-0" style={{ height: "210px" }}>
<div className="relative flex h-[280px] w-full items-center justify-center bg-accent">
<div className="text-sm text-muted-foreground">
데이터가 없습니다
</div>
</div>
</CarouselItem>
)}
</CarouselContent>
<CarouselPrevious className="absolute left-2 top-1/2 z-10 !bg-[rgba(255,255,255,0.8)] text-accent-foreground" />
<CarouselNext className="absolute right-2 top-1/2 z-10 !bg-[rgba(255,255,255,0.8)] text-accent-foreground" />
</Carousel>
</div>
</div>
</>
);
};
export default DataStatusList;
@@ -19,7 +19,7 @@ const NoticeList: React.FC = () => {
queryFn: () =>
PostApi.getNoticePostList({
page: 0,
size: 5,
size: 3,
keywordType: "bbsTypeCd",
keyword: "BBSNOTICE",
}),
@@ -50,7 +50,7 @@ const NoticeList: React.FC = () => {
});
return (
<div className="card card-border !p-0">
<div className="card card-border !p-0" style={{ height: "220px" }}>
<div className="card-header !m-0 !border-none text-foreground">
<h3 className="text-2xl font-medium">공지사항</h3>
<Button className="h-[3.2rem]" onClick={handleMoreClick}>더보기</Button>
@@ -6,33 +6,13 @@ import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import type { PageableList } from "@/shared/model";
import { supportTableColumns } from "../model/support-columns";
import { getSupportList, type Support } from "../api/support";
import { getSupportList, type Support } from "../api/support-service";
const SupportList: React.FC = () => {
const supportQuery = useQuery<PageableList<Support>>({
queryKey: [ENDPOINT.IC_SERVICE.GROUPING_STATISTICS],
queryFn: async () => {
const data = await getSupportList();
return {
content: data,
pageable: {
sort: { empty: true, sorted: false, unsorted: true },
offset: 0,
pageNumber: 0,
pageSize: data.length,
paged: false,
unpaged: true,
},
sort: { empty: true, sorted: false, unsorted: true },
totalPages: 1,
totalElements: data.length,
number: 0,
numberOfElements: data.length,
size: data.length,
first: true,
last: true,
empty: data.length === 0,
};
return await getSupportList();
},
});
@@ -48,7 +28,7 @@ const SupportList: React.FC = () => {
});
return (
<div className="card card-border !p-0">
<div className="card card-border !p-0" style={{ height: "220px" }}>
<div className="card-header !m-0 !border-none text-foreground">
<h3 className="text-2xl font-medium">지원현황</h3>
</div>