[st] WIP: 대시보드 페이지
This commit is contained in:
@@ -14,7 +14,9 @@
|
||||
"@common/components": "workspace:*",
|
||||
"@common/config": "workspace:*",
|
||||
"@common/network": "workspace:*",
|
||||
"@rsone/components": "workspace:*",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@faker-js/faker": "^9.9.0",
|
||||
"@fontsource-variable/noto-sans-kr": "^5.2.5",
|
||||
"@frontend-opensource/use-react-hooks": "^1.1.4",
|
||||
@@ -29,6 +31,7 @@
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@rsone/components": "workspace:*",
|
||||
"@tanstack/react-query": "^5.67.1",
|
||||
"@tanstack/react-query-devtools": "^5.75.5",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
|
||||
@@ -1,9 +1,96 @@
|
||||
const Dashboard = () => {
|
||||
return (
|
||||
<div>
|
||||
Dashboard
|
||||
</div>
|
||||
);
|
||||
};
|
||||
"use client";
|
||||
|
||||
export default Dashboard;
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { closestCenter, DndContext } from "@dnd-kit/core";
|
||||
import { rectSortingStrategy, SortableContext } from "@dnd-kit/sortable";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { useUserStore } from "@/shared/lib";
|
||||
import { Skeleton } from "@common/components";
|
||||
|
||||
import { useDndSensors } from "@/pages/statistics/lib/hooks/use-dnd-sensors";
|
||||
import { usePanelOrder } from "@/pages/statistics/lib/hooks/use-panel-order";
|
||||
import { getInitialPanels } from "@/pages/statistics/model/panel-config";
|
||||
import { SortablePanel } from "@/pages/statistics/ui/sortable-panel";
|
||||
|
||||
interface PanelItem {
|
||||
id: string;
|
||||
component: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const [initialPanels, setInitialPanels] = useState<PanelItem[]>([]);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const sensors = useDndSensors();
|
||||
|
||||
const { panels, handleDragEnd } = usePanelOrder({
|
||||
initialPanels,
|
||||
storageKey: "statistics-dashboard-panels-order",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userInfo) return;
|
||||
const initialPanels = getInitialPanels({
|
||||
dashboardParams: {
|
||||
statsYr: String(dayjs().year() - 1),
|
||||
dtlInstCd: userInfo.dtlInstCd,
|
||||
enoCd: userInfo.enoCd,
|
||||
laeSeCd: userInfo.laeSeCd,
|
||||
},
|
||||
scheduleInfoParams: {
|
||||
statsYr: String(dayjs().year() - 1),
|
||||
},
|
||||
});
|
||||
setInitialPanels(initialPanels);
|
||||
}, [userInfo]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMounted && initialPanels.length > 0 ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={panels.map((p) => p.id)}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
{/* TBD: 그리드 레이아웃 비율 정의 필요
|
||||
현재: 3열 그리드, 반응형 브레이크포인트 적용
|
||||
SPEC 레이아웃에 따라 조정 필요 */}
|
||||
<div className="grid grid-cols-1 gap-4 p-2 md:grid-cols-2 lg:grid-cols-3">
|
||||
{panels.map((panel) => (
|
||||
<SortablePanel
|
||||
key={panel.id}
|
||||
id={panel.id}
|
||||
className={panel.className}
|
||||
>
|
||||
{panel.component}
|
||||
</SortablePanel>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 p-2 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Skeleton className="h-[20rem] rounded-xl" />
|
||||
<Skeleton className="h-[20rem] rounded-xl" />
|
||||
<Skeleton className="h-[20rem] rounded-xl" />
|
||||
<Skeleton className="h-[20rem] rounded-xl" />
|
||||
<Skeleton className="h-[20rem] rounded-xl" />
|
||||
<Skeleton className="h-[20rem] rounded-xl" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type { RecentPostSearchParams } from "../model/post-interface";
|
||||
|
||||
import { postApi } from "./post-service";
|
||||
|
||||
export function useRecentPostList(params?: RecentPostSearchParams) {
|
||||
return useQuery({
|
||||
queryKey: createRecentPostListQueryKey(params),
|
||||
queryFn: () => postApi.getRecentPostList(params),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
function createRecentPostListQueryKey(params?: RecentPostSearchParams) {
|
||||
return [
|
||||
ENDPOINT.BOARD_SERVICE.POSTS,
|
||||
"recentPosts",
|
||||
params?.page ?? "",
|
||||
params?.size ?? "",
|
||||
params?.sort ?? "",
|
||||
params?.keywordType ?? "",
|
||||
params?.keyword ?? "",
|
||||
params?.bbsId ?? "",
|
||||
params?.delYn ?? "",
|
||||
params?.frDateYmd ?? "",
|
||||
params?.toDateYmd ?? "",
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
PostService,
|
||||
RecentPostListResponse,
|
||||
RecentPostSearchParams,
|
||||
} from "../model/post-interface";
|
||||
|
||||
async function getRecentPostList(
|
||||
params?: RecentPostSearchParams,
|
||||
): Promise<RecentPostListResponse> {
|
||||
const response = await client.get<RecentPostListResponse>(
|
||||
`${ENDPOINT.BOARD_SERVICE.POSTS}/recentPosts`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const postApi: PostService = {
|
||||
getRecentPostList,
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export { useRecentPostList } from "./api/post-queries";
|
||||
export { postApi } from "./api/post-service";
|
||||
export {
|
||||
type PostService,
|
||||
type PstRecentListResponse,
|
||||
type RecentPostListResponse,
|
||||
type RecentPostSearchParams,
|
||||
} from "./model/post-interface";
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { PageableList, PageableParams } from "@/shared/model";
|
||||
|
||||
export interface RecentPostSearchParams extends PageableParams {
|
||||
keywordType?: string;
|
||||
keyword?: string;
|
||||
bbsId?: number;
|
||||
delYn?: string;
|
||||
frDateYmd?: string;
|
||||
toDateYmd?: string;
|
||||
}
|
||||
|
||||
export interface PstRecentListResponse {
|
||||
rowNo: number;
|
||||
bbsId: number;
|
||||
pstNo: number;
|
||||
pstTtl: string;
|
||||
pstCn: string;
|
||||
inqNocs: number;
|
||||
rfrncUrl: string;
|
||||
popupYn: string;
|
||||
upndFixYn: string;
|
||||
prdPstgYn: string;
|
||||
rootPath: string;
|
||||
pstPath: string;
|
||||
ansPstn: number;
|
||||
wrtrNm: string;
|
||||
wrtrCplc: string;
|
||||
atflCd: string;
|
||||
regDt: string;
|
||||
rgtrNm: string;
|
||||
cmntCnt: number;
|
||||
bbsTypeCd: string;
|
||||
bbsNm: string;
|
||||
}
|
||||
|
||||
export type RecentPostListResponse = PageableList<PstRecentListResponse>;
|
||||
|
||||
export interface PostService {
|
||||
getRecentPostList(
|
||||
params?: RecentPostSearchParams,
|
||||
): Promise<RecentPostListResponse>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type { ScheduleSearchParams } from "../model/schedule-interface";
|
||||
|
||||
import { scheduleApi } from "./schedule-service";
|
||||
|
||||
export function useSchedules(params?: ScheduleSearchParams) {
|
||||
return useQuery({
|
||||
queryKey: createSchedulesQueryKey(params),
|
||||
queryFn: () => scheduleApi.getSchedules(params),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
function createSchedulesQueryKey(params?: ScheduleSearchParams) {
|
||||
return [
|
||||
ENDPOINT.CMS_SERVICE.SCHEDULES,
|
||||
"list",
|
||||
params?.siteId ?? "",
|
||||
params?.schdlTtl ?? "",
|
||||
params?.schdlSeCd ?? "",
|
||||
params?.schdlBgngYmd ?? "",
|
||||
params?.schdlEndYmd ?? "",
|
||||
params?.reptYn ?? "",
|
||||
params?.commSchdlYn ?? "",
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
Schedule,
|
||||
ScheduleSearchParams,
|
||||
ScheduleService,
|
||||
} from "../model/schedule-interface";
|
||||
|
||||
async function getSchedules(
|
||||
params?: ScheduleSearchParams,
|
||||
): Promise<Schedule[]> {
|
||||
const response = await client.get<Schedule[]>(
|
||||
ENDPOINT.CMS_SERVICE.SCHEDULES,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const scheduleApi: ScheduleService = {
|
||||
getSchedules,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export { useSchedules } from "./api/schedule-queries";
|
||||
export { scheduleApi } from "./api/schedule-service";
|
||||
export {
|
||||
type Schedule,
|
||||
type ScheduleSearchParams,
|
||||
type ScheduleService,
|
||||
} from "./model/schedule-interface";
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface ScheduleSearchParams {
|
||||
siteId?: string;
|
||||
schdlTtl?: string;
|
||||
schdlSeCd?: string;
|
||||
schdlBgngYmd?: string;
|
||||
schdlEndYmd?: string;
|
||||
reptYn?: string;
|
||||
commSchdlYn?: string;
|
||||
}
|
||||
|
||||
export interface Schedule {
|
||||
schdlBgngDt: string;
|
||||
schdlEndDt: string;
|
||||
actualDate: string;
|
||||
schdlSn: number;
|
||||
siteId: string;
|
||||
schdlTtl: string;
|
||||
schdlSeCd: string;
|
||||
schdlCn: string;
|
||||
reptYn: string;
|
||||
commSchdlYn: string;
|
||||
atflCd: string;
|
||||
}
|
||||
|
||||
export interface ScheduleService {
|
||||
getSchedules(params?: ScheduleSearchParams): Promise<Schedule[]>;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type { DashboardSearchParams } from "../../model/dashboard/dashboard-interface";
|
||||
|
||||
import { dashboardApi } from "./dashboard-service";
|
||||
|
||||
// 이상치 선별보고서 조회
|
||||
export function useDashboardVrfcReport(params: DashboardSearchParams | null) {
|
||||
return useQuery({
|
||||
queryKey: createDashboardQueryKey("vrfc-report", params),
|
||||
queryFn: async () => {
|
||||
if (!params)
|
||||
throw new Error("Dashboard search params are required.");
|
||||
const response = await dashboardApi.getVrfcReport(params);
|
||||
return response.data;
|
||||
},
|
||||
enabled: hasRequiredDashboardParams(params),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 검증오류 수 조회
|
||||
export function useDashboardVrfcErrCount(params: DashboardSearchParams | null) {
|
||||
return useQuery({
|
||||
queryKey: createDashboardQueryKey("vrfc-err-count", params),
|
||||
queryFn: async () => {
|
||||
if (!params)
|
||||
throw new Error("Dashboard search params are required.");
|
||||
const response = await dashboardApi.getVrfcErrCount(params);
|
||||
return response.data;
|
||||
},
|
||||
enabled: hasRequiredDashboardParams(params),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 미제출 수 & 목록 조회
|
||||
export function useDashboardUnSubmitCount(
|
||||
params: DashboardSearchParams | null,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: createDashboardQueryKey("un-submit-count", params),
|
||||
queryFn: async () => {
|
||||
if (!params)
|
||||
throw new Error("Dashboard search params are required.");
|
||||
const response = await dashboardApi.getUnSubmitCount(params);
|
||||
return response.data;
|
||||
},
|
||||
enabled: hasRequiredDashboardParams(params),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 통계입력현황 조회
|
||||
export function useDashboardStatsSubmissionRate(
|
||||
params: DashboardSearchParams | null,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: createDashboardQueryKey("stats-submission-rate", params),
|
||||
queryFn: async () => {
|
||||
if (!params)
|
||||
throw new Error("Dashboard search params are required.");
|
||||
const response = await dashboardApi.getStatsSubmissionRate(params);
|
||||
return response.data;
|
||||
},
|
||||
enabled: hasRequiredDashboardParams(params),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 예외신청 수 조회
|
||||
export function useDashboardEcptReqCount(params: DashboardSearchParams | null) {
|
||||
return useQuery({
|
||||
queryKey: createDashboardQueryKey("ecpt-req-count", params),
|
||||
queryFn: async () => {
|
||||
if (!params)
|
||||
throw new Error("Dashboard search params are required.");
|
||||
const response = await dashboardApi.getEcptReqCount(params);
|
||||
return response.data;
|
||||
},
|
||||
enabled: hasRequiredDashboardParams(params),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
function createDashboardQueryKey(
|
||||
name: string,
|
||||
params: DashboardSearchParams | null,
|
||||
) {
|
||||
return [
|
||||
ENDPOINT.ST_SERVICE.DASHBOARD,
|
||||
name,
|
||||
params?.statsYr ?? "",
|
||||
params?.dtlInstCd ?? "",
|
||||
params?.enoCd ?? "",
|
||||
params?.laeSeCd ?? "",
|
||||
];
|
||||
}
|
||||
|
||||
function hasRequiredDashboardParams(
|
||||
params: DashboardSearchParams | null,
|
||||
): params is DashboardSearchParams {
|
||||
return Boolean(params?.statsYr && params.dtlInstCd);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { stClient } from "@/shared/api/client";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
DashboardApiResponse,
|
||||
DashboardSearchParams,
|
||||
DashboardService,
|
||||
EcptReqCountResponse,
|
||||
StatsSubmissionResponse,
|
||||
UnSubmitCountResponse,
|
||||
VrfcErrCountResponse,
|
||||
VrfcReportResponse,
|
||||
} from "../../model/dashboard/dashboard-interface";
|
||||
|
||||
async function getVrfcReport(
|
||||
params: DashboardSearchParams,
|
||||
): Promise<DashboardApiResponse<VrfcReportResponse>> {
|
||||
const response = await stClient.get<
|
||||
DashboardApiResponse<VrfcReportResponse>
|
||||
>(`${ENDPOINT.ST_SERVICE.DASHBOARD}/vrfc-report`, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function getVrfcErrCount(
|
||||
params: DashboardSearchParams,
|
||||
): Promise<DashboardApiResponse<VrfcErrCountResponse>> {
|
||||
const response = await stClient.get<
|
||||
DashboardApiResponse<VrfcErrCountResponse>
|
||||
>(`${ENDPOINT.ST_SERVICE.DASHBOARD}/vrfc-err-count`, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function getUnSubmitCount(
|
||||
params: DashboardSearchParams,
|
||||
): Promise<DashboardApiResponse<UnSubmitCountResponse>> {
|
||||
const response = await stClient.get<
|
||||
DashboardApiResponse<UnSubmitCountResponse>
|
||||
>(`${ENDPOINT.ST_SERVICE.DASHBOARD}/un-submit-count`, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function getStatsSubmissionRate(
|
||||
params: DashboardSearchParams,
|
||||
): Promise<DashboardApiResponse<StatsSubmissionResponse>> {
|
||||
const response = await stClient.get<
|
||||
DashboardApiResponse<StatsSubmissionResponse>
|
||||
>(`${ENDPOINT.ST_SERVICE.DASHBOARD}/stats-submission-rate`, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function getEcptReqCount(
|
||||
params: DashboardSearchParams,
|
||||
): Promise<DashboardApiResponse<EcptReqCountResponse>> {
|
||||
const response = await stClient.get<
|
||||
DashboardApiResponse<EcptReqCountResponse>
|
||||
>(`${ENDPOINT.ST_SERVICE.DASHBOARD}/ecpt-req-count`, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const dashboardApi: DashboardService = {
|
||||
getVrfcReport,
|
||||
getVrfcErrCount,
|
||||
getUnSubmitCount,
|
||||
getStatsSubmissionRate,
|
||||
getEcptReqCount,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type { ScheduleInfoSearchParams } from "../../model/dashboard/schedule-info-interface";
|
||||
|
||||
import { scheduleInfoApi } from "./schedule-info-service";
|
||||
|
||||
export function useScheduleInfo(params: ScheduleInfoSearchParams | null) {
|
||||
return useQuery({
|
||||
queryKey: createScheduleInfoQueryKey(params),
|
||||
queryFn: async () => {
|
||||
if (!params)
|
||||
throw new Error("Schedule info search params are required.");
|
||||
const response = await scheduleInfoApi.getScheduleInfo(params);
|
||||
return response.data;
|
||||
},
|
||||
enabled: hasRequiredScheduleInfoParams(params),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
function createScheduleInfoQueryKey(params: ScheduleInfoSearchParams | null) {
|
||||
return [
|
||||
ENDPOINT.ST_SERVICE.DASHBOARD,
|
||||
"schedule-info",
|
||||
params?.statsYr ?? "",
|
||||
];
|
||||
}
|
||||
|
||||
function hasRequiredScheduleInfoParams(
|
||||
params: ScheduleInfoSearchParams | null,
|
||||
): params is ScheduleInfoSearchParams {
|
||||
return Boolean(params?.statsYr);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { stClient } from "@/shared/api/client";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
ScheduleInfoApiResponse,
|
||||
ScheduleInfoResponse,
|
||||
ScheduleInfoSearchParams,
|
||||
ScheduleInfoService,
|
||||
} from "../../model/dashboard/schedule-info-interface";
|
||||
|
||||
async function getScheduleInfo(
|
||||
params: ScheduleInfoSearchParams,
|
||||
): Promise<ScheduleInfoApiResponse<ScheduleInfoResponse>> {
|
||||
const response = await stClient.get<
|
||||
ScheduleInfoApiResponse<ScheduleInfoResponse>
|
||||
>(`${ENDPOINT.ST_SERVICE.DASHBOARD}/schedule-info`, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const scheduleInfoApi: ScheduleInfoService = {
|
||||
getScheduleInfo,
|
||||
};
|
||||
@@ -1,3 +1,13 @@
|
||||
export {
|
||||
useDashboardEcptReqCount,
|
||||
useDashboardStatsSubmissionRate,
|
||||
useDashboardUnSubmitCount,
|
||||
useDashboardVrfcErrCount,
|
||||
useDashboardVrfcReport,
|
||||
} from "./api/dashboard/dashboard-queries";
|
||||
export { dashboardApi } from "./api/dashboard/dashboard-service";
|
||||
export { useScheduleInfo } from "./api/dashboard/schedule-info-queries";
|
||||
export { scheduleInfoApi } from "./api/dashboard/schedule-info-service";
|
||||
export {
|
||||
useStAnlsSidoCombo,
|
||||
useStAnlsSigunguCombo,
|
||||
@@ -14,6 +24,24 @@ export {
|
||||
type JobStatusDto,
|
||||
type OutlierRecordDto,
|
||||
} from "./model/analysis/analysis-interface";
|
||||
export {
|
||||
type DashboardApiResponse,
|
||||
type DashboardSearchParams,
|
||||
type DashboardService,
|
||||
type EcptReqCountResponse,
|
||||
type StatsSubmissionResponse,
|
||||
type UnSubmitCountResponse,
|
||||
type UnSubmitStatsResponse,
|
||||
type VrfcErrCountResponse,
|
||||
type VrfcReportResponse,
|
||||
type VrfcReportSelect,
|
||||
} from "./model/dashboard/dashboard-interface";
|
||||
export {
|
||||
type ScheduleInfoApiResponse,
|
||||
type ScheduleInfoResponse,
|
||||
type ScheduleInfoSearchParams,
|
||||
type ScheduleInfoService,
|
||||
} from "./model/dashboard/schedule-info-interface";
|
||||
export { type VerifyResultItem } from "./model/general-verification-interface";
|
||||
export {
|
||||
IMR_DETAIL_VISIBLE_COLUMNS_BY_STATS_CD,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
export interface DashboardSearchParams {
|
||||
statsYr: string;
|
||||
dtlInstCd: string;
|
||||
enoCd?: string;
|
||||
laeSeCd?: string;
|
||||
}
|
||||
|
||||
export interface DashboardApiResponse<T> {
|
||||
status: string;
|
||||
message: string;
|
||||
data: T;
|
||||
code: string;
|
||||
httpStatus: number;
|
||||
}
|
||||
|
||||
export interface VrfcReportResponse {
|
||||
typeCounts: Record<string, number>;
|
||||
list: VrfcReportSelect[];
|
||||
}
|
||||
|
||||
export interface VrfcReportSelect {
|
||||
vrfcCd: string;
|
||||
statsNm: string;
|
||||
rsnExpln: string;
|
||||
admdstNm: string;
|
||||
nowVlCn: string;
|
||||
compVlCn: string;
|
||||
}
|
||||
|
||||
export interface VrfcErrCountResponse {
|
||||
statsYr: string;
|
||||
vrfcErrCount: number;
|
||||
}
|
||||
|
||||
export interface UnSubmitCountResponse {
|
||||
statsYr: string;
|
||||
unSubmitCount: number;
|
||||
unSubmitList: UnSubmitStatsResponse[];
|
||||
}
|
||||
|
||||
export interface UnSubmitStatsResponse {
|
||||
statsCd: string;
|
||||
statsNm: string;
|
||||
}
|
||||
|
||||
export interface StatsSubmissionResponse {
|
||||
totalCount: number;
|
||||
submittedCount: number;
|
||||
submissionRate: number;
|
||||
statusLevel: string;
|
||||
}
|
||||
|
||||
export interface EcptReqCountResponse {
|
||||
statsYr: string;
|
||||
ecptReqCount: number;
|
||||
}
|
||||
|
||||
export interface DashboardService {
|
||||
getVrfcReport(
|
||||
params: DashboardSearchParams,
|
||||
): Promise<DashboardApiResponse<VrfcReportResponse>>;
|
||||
getVrfcErrCount(
|
||||
params: DashboardSearchParams,
|
||||
): Promise<DashboardApiResponse<VrfcErrCountResponse>>;
|
||||
getUnSubmitCount(
|
||||
params: DashboardSearchParams,
|
||||
): Promise<DashboardApiResponse<UnSubmitCountResponse>>;
|
||||
getStatsSubmissionRate(
|
||||
params: DashboardSearchParams,
|
||||
): Promise<DashboardApiResponse<StatsSubmissionResponse>>;
|
||||
getEcptReqCount(
|
||||
params: DashboardSearchParams,
|
||||
): Promise<DashboardApiResponse<EcptReqCountResponse>>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface ScheduleInfoSearchParams {
|
||||
statsYr: string;
|
||||
}
|
||||
|
||||
export interface ScheduleInfoApiResponse<T> {
|
||||
status: string;
|
||||
message: string;
|
||||
data: T;
|
||||
code: string;
|
||||
httpStatus: number;
|
||||
}
|
||||
|
||||
export interface ScheduleInfoResponse {
|
||||
statsYr: string;
|
||||
inptSeCd: number;
|
||||
inptBgngYmd: string;
|
||||
inptEndYmd: string;
|
||||
vrfcSeCd: number;
|
||||
vrfcBgngYmd: string;
|
||||
vrfcEndYmd: string;
|
||||
currentSe: string;
|
||||
dday: string;
|
||||
ddayLabel: string;
|
||||
}
|
||||
|
||||
export interface ScheduleInfoService {
|
||||
getScheduleInfo(
|
||||
params: ScheduleInfoSearchParams,
|
||||
): Promise<ScheduleInfoApiResponse<ScheduleInfoResponse>>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
|
||||
export function useDndSensors() {
|
||||
return useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { DragEndEvent } from "@dnd-kit/core";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
|
||||
import type { PanelItem } from "../../model/types";
|
||||
|
||||
interface UsePanelOrderProps {
|
||||
initialPanels: PanelItem[];
|
||||
storageKey: string;
|
||||
}
|
||||
|
||||
export function usePanelOrder({
|
||||
initialPanels,
|
||||
storageKey,
|
||||
}: UsePanelOrderProps) {
|
||||
const [panels, setPanels] = useState<PanelItem[]>(initialPanels);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialPanels.length === 0 || isInitialized) return;
|
||||
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
try {
|
||||
const savedIds = JSON.parse(saved);
|
||||
const restoredPanels = savedIds
|
||||
.map((id: string) => initialPanels.find((p) => p.id === id))
|
||||
.filter(Boolean);
|
||||
if (restoredPanels.length === initialPanels.length) {
|
||||
setPanels(restoredPanels);
|
||||
}
|
||||
} catch {
|
||||
setPanels(initialPanels);
|
||||
}
|
||||
} else {
|
||||
setPanels(initialPanels);
|
||||
}
|
||||
setIsInitialized(true);
|
||||
}, [storageKey, initialPanels, isInitialized]);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
setPanels((items) => {
|
||||
const oldIndex = items.findIndex(
|
||||
(item) => item.id === active.id,
|
||||
);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify(newItems.map((item) => item.id)),
|
||||
);
|
||||
|
||||
return newItems;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return { panels, handleDragEnd };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { DashboardSearchParams } from "@/entities/statistics/model/dashboard/dashboard-interface";
|
||||
import type { ScheduleInfoSearchParams } from "@/entities/statistics/model/dashboard/schedule-info-interface";
|
||||
|
||||
import { NoticePanel } from "../ui/panels/notice-panel";
|
||||
import { ScheduleCalendarPanel } from "../ui/panels/schedule-calendar-panel";
|
||||
import { ScheduleInfoPanel } from "../ui/panels/schedule-info-panel";
|
||||
import { StatsSubmissionPanel } from "../ui/panels/stats-submission-panel";
|
||||
import { UnSubmitPanel } from "../ui/panels/unsubmit-panel";
|
||||
import { VrfcErrPanel } from "../ui/panels/vrfc-err-panel";
|
||||
import { VrfcReportPanel } from "../ui/panels/vrfc-report-panel";
|
||||
|
||||
import type { PanelItem } from "./types";
|
||||
|
||||
interface GetInitialPanelsParams {
|
||||
dashboardParams: DashboardSearchParams | null;
|
||||
scheduleInfoParams: ScheduleInfoSearchParams | null;
|
||||
}
|
||||
|
||||
export function getInitialPanels({
|
||||
dashboardParams,
|
||||
scheduleInfoParams,
|
||||
}: GetInitialPanelsParams): PanelItem[] {
|
||||
return [
|
||||
{
|
||||
id: "panel-a",
|
||||
component: <StatsSubmissionPanel params={dashboardParams} />,
|
||||
className: "",
|
||||
},
|
||||
{
|
||||
id: "panel-b",
|
||||
component: <ScheduleInfoPanel params={scheduleInfoParams} />,
|
||||
className: "",
|
||||
},
|
||||
{
|
||||
id: "panel-c",
|
||||
component: <NoticePanel />,
|
||||
className: "",
|
||||
},
|
||||
{
|
||||
id: "panel-d",
|
||||
component: <VrfcErrPanel params={dashboardParams} />,
|
||||
className: "",
|
||||
},
|
||||
{
|
||||
id: "panel-e",
|
||||
component: <ScheduleCalendarPanel />,
|
||||
className: "",
|
||||
},
|
||||
{
|
||||
id: "panel-f",
|
||||
component: <VrfcReportPanel params={dashboardParams} />,
|
||||
className: "",
|
||||
},
|
||||
{
|
||||
id: "panel-g",
|
||||
component: <UnSubmitPanel params={dashboardParams} />,
|
||||
className: "",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface PanelItem {
|
||||
id: string;
|
||||
component: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { createColumnHelper, getCoreRowModel } from "@tanstack/react-table";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { type PstRecentListResponse, useRecentPostList } from "@/entities/post";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { useDragHandle } from "../sortable-panel";
|
||||
|
||||
export function NoticePanel() {
|
||||
const { dragHandleProps, isDragging } = useDragHandle();
|
||||
const router = useRouter();
|
||||
|
||||
const recentPostListQuery = useRecentPostList({
|
||||
page: 0,
|
||||
size: 3,
|
||||
keywordType: "bbsTypeCd",
|
||||
keyword: "BBSNOTICE",
|
||||
});
|
||||
|
||||
const handleRowClick = (post: PstRecentListResponse) => {
|
||||
router.push(
|
||||
`/cms/${post.bbsTypeCd.toLowerCase()}/${post.bbsId}/${post.pstNo}`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleMoreClick = () => {
|
||||
router.push("/cms/bbsnotice/7");
|
||||
};
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
query: recentPostListQuery,
|
||||
tableOptions: {
|
||||
columns: noticeColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
},
|
||||
dataTableOptions: {
|
||||
variant: "line",
|
||||
onRowClick: handleRowClick,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="card card-border !m-0 h-full">
|
||||
<div
|
||||
className={`card-header cursor-grab active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
|
||||
{...dragHandleProps}
|
||||
title="헤더를 드래그하여 패널 순서 변경"
|
||||
>
|
||||
<h3 className="text-lg font-semibold">공지사항</h3>
|
||||
<Button onClick={handleMoreClick} className="h-8">
|
||||
더보기
|
||||
</Button>
|
||||
</div>
|
||||
{tableComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<PstRecentListResponse>();
|
||||
|
||||
const noticeColumns = [
|
||||
columnHelper.accessor("pstTtl", {
|
||||
header: "제목",
|
||||
cell: ({ row }) => (
|
||||
<div className="py-2">
|
||||
<div className="truncate font-medium">
|
||||
{row.original.pstTtl}
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-sm text-muted-foreground">
|
||||
<span>{row.original.wrtrNm}</span>
|
||||
<span>{formatDate(row.original.regDt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
];
|
||||
|
||||
function formatDate(value: string) {
|
||||
return dayjs(value).format("YYYY-MM-DD");
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { SITE_ID } from "@/shared/config";
|
||||
import { useSchedules } from "@/entities/schedule";
|
||||
|
||||
import { useDragHandle } from "../sortable-panel";
|
||||
|
||||
// FullCalendar는 클라이언트 전용이므로 dynamic import
|
||||
const FullCalendar = dynamic(() => import("@fullcalendar/react"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export function ScheduleCalendarPanel() {
|
||||
const { dragHandleProps, isDragging } = useDragHandle();
|
||||
|
||||
// TBD: 초기 날짜 범위 설정 로직
|
||||
const today = dayjs();
|
||||
const startOfMonth = today.startOf("month").format("YYYY-MM-DD");
|
||||
const endOfMonth = today.endOf("month").format("YYYY-MM-DD");
|
||||
|
||||
const { data, isLoading } = useSchedules({
|
||||
siteId: SITE_ID,
|
||||
schdlBgngYmd: startOfMonth,
|
||||
schdlEndYmd: endOfMonth,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="card card-border !m-0 h-full">
|
||||
<div
|
||||
className={`card-header cursor-grab active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
|
||||
{...dragHandleProps}
|
||||
title="헤더를 드래그하여 패널 순서 변경"
|
||||
>
|
||||
<h3 className="text-lg font-semibold">일정관리</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{isLoading && (
|
||||
<div className="py-8 text-center">로딩 중...</div>
|
||||
)}
|
||||
{data && (
|
||||
<div className="h-[400px]">
|
||||
{/* TBD: FullCalendar 상세 설정 (locale, plugins, 이벤트 색상 등) */}
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, timeGridPlugin]}
|
||||
initialView="dayGridMonth"
|
||||
locale="ko"
|
||||
headerToolbar={{
|
||||
left: "prev,next",
|
||||
center: "title",
|
||||
right: "dayGridMonth,timeGridWeek",
|
||||
}}
|
||||
buttonText={{
|
||||
month: "월",
|
||||
week: "주",
|
||||
}}
|
||||
events={data.map((schedule) => ({
|
||||
id: schedule.schdlSn.toString(),
|
||||
title: schedule.schdlTtl,
|
||||
start: schedule.schdlBgngDt,
|
||||
end: schedule.schdlEndDt,
|
||||
// TBD: allDay 판단 기준 (1439분 이상?)
|
||||
allDay: true,
|
||||
}))}
|
||||
// TBD: 이벤트 클릭 핸들러 (현재 SPEC에서 제거됨)
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import type { ScheduleInfoSearchParams } from "@/entities/statistics";
|
||||
import { useScheduleInfo } from "@/entities/statistics";
|
||||
|
||||
import { useDragHandle } from "../sortable-panel";
|
||||
|
||||
interface ScheduleInfoPanelProps {
|
||||
params: ScheduleInfoSearchParams | null;
|
||||
}
|
||||
|
||||
export function ScheduleInfoPanel({ params }: ScheduleInfoPanelProps) {
|
||||
const { dragHandleProps, isDragging } = useDragHandle();
|
||||
const { data, isLoading, error } = useScheduleInfo(params);
|
||||
|
||||
return (
|
||||
<div className="card card-border !m-0 h-full">
|
||||
<div
|
||||
className={`card-header cursor-grab active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
|
||||
{...dragHandleProps}
|
||||
title="헤더를 드래그하여 패널 순서 변경"
|
||||
>
|
||||
<h3 className="text-lg font-semibold">일정안내</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{isLoading && (
|
||||
<div className="py-8 text-center">로딩 중...</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="py-8 text-center text-red-500">
|
||||
데이터를 불러올 수 없습니다.
|
||||
</div>
|
||||
)}
|
||||
{data && (
|
||||
<div className="space-y-4">
|
||||
{/* D-day 표시 */}
|
||||
<div className="rounded-lg bg-blue-50 p-4 text-center">
|
||||
<div className="mb-1 text-sm text-muted-foreground">
|
||||
{data.ddayLabel}
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{data.dday}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{data.currentSe}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TBD: 일정 목록 표시 형식 정의 필요 */}
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border p-3">
|
||||
<div className="mb-1 text-sm font-medium">
|
||||
입력기간
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{data.inptBgngYmd} ~ {data.inptEndYmd}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded border p-3">
|
||||
<div className="mb-1 text-sm font-medium">
|
||||
검증기간
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{data.vrfcBgngYmd} ~ {data.vrfcEndYmd}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useDashboardStatsSubmissionRate } from "@/entities/statistics";
|
||||
import type { DashboardSearchParams } from "@/entities/statistics/model/dashboard/dashboard-interface";
|
||||
import { LoadingWithAnimation } from "@common/components";
|
||||
|
||||
import { useDragHandle } from "../sortable-panel";
|
||||
|
||||
interface StatsSubmissionPanelProps {
|
||||
params: DashboardSearchParams | null;
|
||||
}
|
||||
|
||||
export function StatsSubmissionPanel({ params }: StatsSubmissionPanelProps) {
|
||||
const { dragHandleProps, isDragging } = useDragHandle();
|
||||
const { data, isLoading, error } = useDashboardStatsSubmissionRate(params);
|
||||
|
||||
return (
|
||||
<div className="card card-border !m-0 h-full">
|
||||
<div
|
||||
className={`card-header cursor-grab active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
|
||||
{...dragHandleProps}
|
||||
title="헤더를 드래그하여 패널 순서 변경"
|
||||
>
|
||||
<h3 className="text-xl font-semibold">통계입력현황</h3>
|
||||
</div>
|
||||
<div className="card-body h-[calc(100%-3.25rem)] py-2.5">
|
||||
{isLoading && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="relative size-[5rem]">
|
||||
<LoadingWithAnimation color="primary" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex h-full items-center justify-center py-8 text-center">
|
||||
데이터를 불러올 수 없습니다.
|
||||
</div>
|
||||
)}
|
||||
{data && (
|
||||
<div className="space-y-4">
|
||||
{/* TBD: statusLevel별 이모지 매핑 규칙 필요 */}
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">
|
||||
{/* TBD: statusLevel 값에 따른 이모지 표시 */}
|
||||
{data.statusLevel === "GOOD"
|
||||
? "😊"
|
||||
: data.statusLevel === "WARNING"
|
||||
? "⚠️"
|
||||
: "📊"}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{data.statusLevel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계제출률 프로그레스바 */}
|
||||
<div>
|
||||
<div className="mb-2 flex justify-between text-sm">
|
||||
<span>통계제출률</span>
|
||||
<span className="font-semibold">
|
||||
{data.submissionRate?.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2.5 w-full rounded-full bg-gray-200">
|
||||
<div
|
||||
className="h-2.5 rounded-full bg-blue-600 transition-all"
|
||||
style={{ width: `${data.submissionRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
|
||||
<span>제출: {data.submittedCount}</span>
|
||||
<span>전체: {data.totalCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useDashboardUnSubmitCount } from "@/entities/statistics";
|
||||
import type { DashboardSearchParams } from "@/entities/statistics/model/dashboard/dashboard-interface";
|
||||
import { Badge, LoadingWithAnimation } from "@common/components";
|
||||
|
||||
import { useDragHandle } from "../sortable-panel";
|
||||
|
||||
interface UnSubmitPanelProps {
|
||||
params: DashboardSearchParams | null;
|
||||
}
|
||||
|
||||
export function UnSubmitPanel({ params }: UnSubmitPanelProps) {
|
||||
const { dragHandleProps, isDragging } = useDragHandle();
|
||||
const { data, isLoading, error } = useDashboardUnSubmitCount(params);
|
||||
|
||||
return (
|
||||
<div className="card card-border !m-0 h-full">
|
||||
<div
|
||||
className={`card-header cursor-grab active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
|
||||
{...dragHandleProps}
|
||||
title="헤더를 드래그하여 패널 순서 변경"
|
||||
>
|
||||
<h3 className="text-xl font-semibold">미제출 지표</h3>
|
||||
{data && (
|
||||
<Badge
|
||||
color="red"
|
||||
className="text-md rounded-full px-2 py-1 text-destructive-foreground"
|
||||
>
|
||||
{data.unSubmitCount}건
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-body h-[calc(100%-3.25rem)] py-2.5">
|
||||
{isLoading && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="relative size-[5rem]">
|
||||
<LoadingWithAnimation color="primary" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
데이터를 불러올 수 없습니다.
|
||||
</div>
|
||||
)}
|
||||
{data && (
|
||||
<div className="h-full">
|
||||
{data.unSubmitList.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
미제출 지표가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[32rem] space-y-2 overflow-y-auto">
|
||||
{data.unSubmitList.map((item) => (
|
||||
<div
|
||||
key={item.statsCd}
|
||||
className="rounded-sm border p-3"
|
||||
>
|
||||
<p className="font-medium">
|
||||
{item.statsNm}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { OctagonAlert, OctagonX } from "lucide-react";
|
||||
|
||||
import {
|
||||
useDashboardEcptReqCount,
|
||||
useDashboardVrfcErrCount,
|
||||
} from "@/entities/statistics";
|
||||
import type { DashboardSearchParams } from "@/entities/statistics/model/dashboard/dashboard-interface";
|
||||
|
||||
import { useDragHandle } from "../sortable-panel";
|
||||
|
||||
interface VrfcErrPanelProps {
|
||||
params: DashboardSearchParams | null;
|
||||
}
|
||||
|
||||
export function VrfcErrPanel({ params }: VrfcErrPanelProps) {
|
||||
const { dragHandleProps, isDragging } = useDragHandle();
|
||||
const vrfcErrQuery = useDashboardVrfcErrCount(params);
|
||||
const ecptReqQuery = useDashboardEcptReqCount(params);
|
||||
|
||||
return (
|
||||
<div className="card card-border !m-0 h-full">
|
||||
<div
|
||||
className={`card-header cursor-grab active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
|
||||
{...dragHandleProps}
|
||||
title="헤더를 드래그하여 패널 순서 변경"
|
||||
>
|
||||
<h3 className="text-xl font-semibold">검증오류 및 예외</h3>
|
||||
</div>
|
||||
<div className="card-body h-[calc(100%-3.25rem)] py-2.5">
|
||||
<div className="grid h-full grid-cols-2 gap-4">
|
||||
{/* 검증오류 수 카드 */}
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-red-200 bg-red-100 p-4 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<OctagonX className="text-red-600" size={20} />
|
||||
<span className="text-lg">검증오류 수</span>
|
||||
</div>
|
||||
{vrfcErrQuery.isLoading && (
|
||||
<div className="text-2xl font-bold">-</div>
|
||||
)}
|
||||
{vrfcErrQuery.error && (
|
||||
<div className="text-sm">
|
||||
데이터를 불러올 수 없습니다.
|
||||
</div>
|
||||
)}
|
||||
{vrfcErrQuery.data && (
|
||||
<div className="text-5xl font-bold text-red-600">
|
||||
{vrfcErrQuery.data.vrfcErrCount.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 예외신청 수 카드 */}
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-orange-200 bg-orange-100 p-4 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<OctagonAlert
|
||||
className="text-orange-600"
|
||||
size={20}
|
||||
/>
|
||||
<span className="text-lg">예외신청 수</span>
|
||||
</div>
|
||||
{ecptReqQuery.isLoading && (
|
||||
<div className="text-2xl font-bold">-</div>
|
||||
)}
|
||||
{ecptReqQuery.error && (
|
||||
<div className="text-sm">
|
||||
데이터를 불러올 수 없습니다.
|
||||
</div>
|
||||
)}
|
||||
{ecptReqQuery.data && (
|
||||
<div className="text-5xl font-bold text-orange-600">
|
||||
{ecptReqQuery.data.ecptReqCount.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useDashboardVrfcReport } from "@/entities/statistics";
|
||||
import type { DashboardSearchParams } from "@/entities/statistics/model/dashboard/dashboard-interface";
|
||||
|
||||
import { useDragHandle } from "../sortable-panel";
|
||||
|
||||
interface VrfcReportPanelProps {
|
||||
params: DashboardSearchParams | null;
|
||||
}
|
||||
|
||||
export function VrfcReportPanel({ params }: VrfcReportPanelProps) {
|
||||
const { dragHandleProps, isDragging } = useDragHandle();
|
||||
const { data, isLoading, error } = useDashboardVrfcReport(params);
|
||||
|
||||
return (
|
||||
<div className="card card-border !m-0 h-full">
|
||||
<div
|
||||
className={`card-header cursor-grab active:cursor-grabbing ${isDragging ? "opacity-50" : ""}`}
|
||||
{...dragHandleProps}
|
||||
title="헤더를 드래그하여 패널 순서 변경"
|
||||
>
|
||||
<h3 className="text-lg font-semibold">이상치 선별보고서</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{isLoading && (
|
||||
<div className="py-8 text-center">로딩 중...</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="py-8 text-center text-red-500">
|
||||
데이터를 불러올 수 없습니다.
|
||||
</div>
|
||||
)}
|
||||
{data && (
|
||||
<div className="space-y-4">
|
||||
{/* TBD: typeCounts 표시 방법 (탭? 필터?) */}
|
||||
{data.typeCounts &&
|
||||
Object.keys(data.typeCounts).length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(data.typeCounts).map(
|
||||
([type, count]) => (
|
||||
<span
|
||||
key={type}
|
||||
className="rounded-full bg-gray-100 px-3 py-1 text-sm"
|
||||
>
|
||||
{type}: {count}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TBD: 테이블 컬럼 정의, 증감 표시 방법 */}
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{data.list.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
이상치 항목이 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.list.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="cursor-pointer rounded border p-3 transition-colors hover:bg-accent"
|
||||
/* TBD: 행 클릭 시 이동 라우트 */
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<div className="font-medium">
|
||||
{item.statsNm}
|
||||
</div>
|
||||
{/* TBD: vrfcCd 값별 표시 레이블 */}
|
||||
<span className="rounded bg-red-100 px-2 py-0.5 text-xs text-red-700">
|
||||
{item.vrfcCd}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-1 text-sm text-muted-foreground">
|
||||
{item.rsnExpln}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{item.admdstNm}</span>
|
||||
<span>
|
||||
{/* TBD: 증감 표시 방법 (nowVlCn - compVlCn 계산?) */}
|
||||
현재: {item.nowVlCn} / 비교:{" "}
|
||||
{item.compVlCn}
|
||||
</span>
|
||||
</div>
|
||||
{/* TBD: 등급/점수/단계/업데이트 시각 필드 미제공 */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
interface SortablePanelProps {
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DragHandleContextValue {
|
||||
dragHandleProps: React.HTMLAttributes<HTMLElement>;
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
const DragHandleContext = createContext<DragHandleContextValue | null>(null);
|
||||
|
||||
export function SortablePanel({ id, children, className }: SortablePanelProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
const contextValue: DragHandleContextValue = {
|
||||
dragHandleProps: { ...attributes, ...listeners },
|
||||
isDragging,
|
||||
};
|
||||
|
||||
return (
|
||||
<DragHandleContext.Provider value={contextValue}>
|
||||
<div ref={setNodeRef} style={style} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
</DragHandleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDragHandle() {
|
||||
const context = useContext(DragHandleContext);
|
||||
|
||||
if (!context) {
|
||||
return {
|
||||
dragHandleProps: {},
|
||||
isDragging: false,
|
||||
};
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -74,6 +74,7 @@ export const ENDPOINT = {
|
||||
INPUTVERIFY: "/st-input-service/api/v1/verify",
|
||||
TOTAL: "/st-management-service/api/v1/stats",
|
||||
APPROVAL: "/st-management-service/api/stats",
|
||||
DASHBOARD: "/st-management-service/api/v1/dashboard",
|
||||
},
|
||||
STATVERIFY_SERVICE: {
|
||||
VRFCTGT_MNG: "/statverify-service/api/v1/vrfc-trgt-mng",
|
||||
|
||||
@@ -1,42 +1,46 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import { useReactTable, type TableOptions } from "@tanstack/react-table";
|
||||
import { type TableOptions, useReactTable } from "@tanstack/react-table";
|
||||
|
||||
import { DataTable } from "@common/components";
|
||||
import { NoData } from "../ui/no-data";
|
||||
|
||||
import type { PageableList } from "../model";
|
||||
import { NoData } from "../ui/no-data";
|
||||
|
||||
import { matchQueryStatus } from "./match-query-status";
|
||||
|
||||
type UseDataTableOptions<T> = {
|
||||
query: UseQueryResult<PageableList<T>>;
|
||||
tableOptions: Omit<TableOptions<T>, "data" | "rowCount">;
|
||||
dataTableOptions?: DataTableOptions<T>;
|
||||
query: UseQueryResult<PageableList<T>>;
|
||||
tableOptions: Omit<TableOptions<T>, "data" | "rowCount">;
|
||||
dataTableOptions?: DataTableOptions<T>;
|
||||
};
|
||||
|
||||
type DataTableOptions<T> = {
|
||||
variant?: "default" | "secondary" | "line";
|
||||
className?: HTMLAttributes<HTMLTableElement>["className"];
|
||||
viewPagination?: boolean;
|
||||
onRowClick?: (row: T) => void;
|
||||
variant?: "default" | "secondary" | "line";
|
||||
className?: HTMLAttributes<HTMLTableElement>["className"];
|
||||
viewPagination?: boolean;
|
||||
onRowClick?: (row: T) => void;
|
||||
};
|
||||
|
||||
export const useDataTable = <T,>({
|
||||
query,
|
||||
tableOptions,
|
||||
dataTableOptions,
|
||||
query,
|
||||
tableOptions,
|
||||
dataTableOptions,
|
||||
}: UseDataTableOptions<T>) => {
|
||||
const table = useReactTable({
|
||||
...tableOptions,
|
||||
data: query.data?.content ?? [],
|
||||
rowCount: query.data?.totalElements ?? 0,
|
||||
});
|
||||
const table = useReactTable({
|
||||
...tableOptions,
|
||||
data: query.data?.content ?? [],
|
||||
rowCount: query.data?.totalElements ?? 0,
|
||||
});
|
||||
|
||||
return {
|
||||
tableComponent: matchQueryStatus(query, {
|
||||
Empty: <NoData />,
|
||||
Success: () => {
|
||||
return <DataTable table={table} {...dataTableOptions} />;
|
||||
},
|
||||
}),
|
||||
tableInstance: table,
|
||||
};
|
||||
return {
|
||||
tableComponent: matchQueryStatus(query, {
|
||||
Empty: <NoData />,
|
||||
Success: () => {
|
||||
return <DataTable table={table} {...dataTableOptions} />;
|
||||
},
|
||||
}),
|
||||
tableInstance: table,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useStore } from "zustand";
|
||||
import { useContext } from "react";
|
||||
import type { UserStore } from "../store/user-store";
|
||||
|
||||
import { useStore } from "zustand";
|
||||
|
||||
import { StoreContext } from "../providers/store-provider";
|
||||
import type { UserStore } from "../store/user-store";
|
||||
|
||||
export const useUserStore = <T>(selector: (store: UserStore) => T): T => {
|
||||
const storeContext = useContext(StoreContext);
|
||||
const storeContext = useContext(StoreContext);
|
||||
|
||||
if (!storeContext) {
|
||||
throw new Error("useUserStore must be used within StoreProvider");
|
||||
}
|
||||
if (!storeContext) {
|
||||
throw new Error("useUserStore must be used within StoreProvider");
|
||||
}
|
||||
|
||||
return useStore(storeContext.userStore, selector);
|
||||
return useStore(storeContext.userStore, selector);
|
||||
};
|
||||
|
||||
@@ -1,59 +1,61 @@
|
||||
import { createStore } from "zustand/vanilla";
|
||||
|
||||
export interface IUserInfo {
|
||||
regDt: string;
|
||||
mdfcnDt: string;
|
||||
rgtr: string;
|
||||
mdfr: string;
|
||||
adminRoleYn: string;
|
||||
userUnqId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
email: string;
|
||||
encryptedPassword: string;
|
||||
refreshToken: string;
|
||||
userStateCode: string;
|
||||
lastLoginDate: string;
|
||||
loginFailCount: number;
|
||||
pswdHnt: string;
|
||||
pswdAns: string;
|
||||
mphn: string;
|
||||
telno: string;
|
||||
zip: string;
|
||||
addr: string;
|
||||
daddr: string;
|
||||
laeSeCd: string;
|
||||
/** 기관 구분 코드 — 통계 공통 조회 등 query `instSeCd`로 전달 */
|
||||
instSeCd?: string;
|
||||
ogdpSeCd: string;
|
||||
aprvYn: string;
|
||||
autzrId: string;
|
||||
expryYmd: string;
|
||||
lastPswdChgDt: string;
|
||||
googleId: string;
|
||||
kakaoId: string;
|
||||
naverId: string;
|
||||
isSocialUser: boolean;
|
||||
hasPassword: boolean;
|
||||
regDt: string;
|
||||
mdfcnDt: string;
|
||||
rgtr: string;
|
||||
mdfr: string;
|
||||
adminRoleYn: string;
|
||||
userUnqId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
email: string;
|
||||
encryptedPassword: string;
|
||||
enoCd: string;
|
||||
refreshToken: string;
|
||||
userStateCode: string;
|
||||
lastLoginDate: string;
|
||||
loginFailCount: number;
|
||||
pswdHnt: string;
|
||||
pswdAns: string;
|
||||
mphn: string;
|
||||
telno: string;
|
||||
zip: string;
|
||||
addr: string;
|
||||
daddr: string;
|
||||
dtlInstCd: string;
|
||||
laeSeCd: string;
|
||||
/** 기관 구분 코드 — 통계 공통 조회 등 query `instSeCd`로 전달 */
|
||||
instSeCd?: string;
|
||||
ogdpSeCd: string;
|
||||
aprvYn: string;
|
||||
autzrId: string;
|
||||
expryYmd: string;
|
||||
lastPswdChgDt: string;
|
||||
googleId: string;
|
||||
kakaoId: string;
|
||||
naverId: string;
|
||||
isSocialUser: boolean;
|
||||
hasPassword: boolean;
|
||||
}
|
||||
|
||||
export type UserState = {
|
||||
userInfo: IUserInfo | null;
|
||||
userInfo: IUserInfo | null;
|
||||
};
|
||||
|
||||
export type UserActions = {
|
||||
setUserInfo: (userInfo: IUserInfo | null) => void;
|
||||
setUserInfo: (userInfo: IUserInfo | null) => void;
|
||||
};
|
||||
|
||||
export type UserStore = UserState & UserActions;
|
||||
|
||||
export const defaultInitState: UserState = {
|
||||
userInfo: null,
|
||||
userInfo: null,
|
||||
};
|
||||
|
||||
export const createUserStore = (initState: UserState = defaultInitState) => {
|
||||
return createStore<UserStore>()((set) => ({
|
||||
...initState,
|
||||
setUserInfo: (userInfo: IUserInfo | null) => set(() => ({ userInfo })),
|
||||
}));
|
||||
return createStore<UserStore>()((set) => ({
|
||||
...initState,
|
||||
setUserInfo: (userInfo: IUserInfo | null) => set(() => ({ userInfo })),
|
||||
}));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user