[st] WIP: 대시보드 페이지

This commit is contained in:
2026-04-28 10:16:01 +09:00
parent d462c50684
commit 47b582c5cc
33 changed files with 1503 additions and 83 deletions
+4 -1
View File
@@ -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,
};
+8
View File
@@ -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,
};
+7
View File
@@ -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,
};
+28
View File
@@ -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;
}
+1
View File
@@ -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",
+30 -26
View File
@@ -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,
};
};
+9 -7
View File
@@ -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);
};
+43 -41
View File
@@ -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 })),
}));
};