This commit is contained in:
2026-04-28 15:19:19 +09:00
41 changed files with 1626 additions and 204 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;
@@ -129,6 +129,7 @@ const DEFAULT_FILTERS: StatisticsVerificationFilterState = {
statsYr: String(dayjs().year() - 1),
sewerBzmnCd: null,
statsCd: null,
laeSbmsnSttsCd: "2",
};
const DEFAULT_SUB_FILTERS: StatisticsVerificationSubFilterState = {
@@ -156,15 +157,6 @@ function StatisticsVerificationReport() {
useState<StatisticsVerificationFilterState>(DEFAULT_FILTERS);
const [subFilters, setSubFilters] =
useState<StatisticsVerificationSubFilterState>(DEFAULT_SUB_FILTERS);
const [submittedFilters, setSubmittedFilters] =
useState<StatisticsVerificationFilterState>(DEFAULT_FILTERS);
const [submittedParams, setSubmittedParams] = useState<{
main: StatisticsVerificationFilterState;
sub: StatisticsVerificationSubFilterState;
}>({
main: DEFAULT_FILTERS,
sub: DEFAULT_SUB_FILTERS,
});
const [selectedReportRows, setSelectedReportRows] = useState<
StatsInputStatusList[]
>([]);
@@ -182,11 +174,30 @@ function StatisticsVerificationReport() {
const { data: sigunguData } = useStAnlsSigunguCombo(
filters.sewerBzmnCd ?? "",
);
const detailParams = useMemo(
() => ({
statsYr: filters.statsYr,
ctpvNm:
subFilters.ctpvNm !== "" ? subFilters.ctpvNm : filters.ctpvNm,
sggNm: subFilters.sggNm !== "" ? subFilters.sggNm : filters.sggNm,
sewerBzmnCd:
subFilters.sewerBzmnCd !== ""
? subFilters.sewerBzmnCd
: filters.sewerBzmnCd,
statsCd:
subFilters.statsCd !== ""
? subFilters.statsCd
: filters.statsCd,
prcsYnList: subFilters.prcsYnList,
pbcpAprvYn: subFilters.pbcpAprvYn,
laeSbmsnSttsCd: subFilters.laeSbmsnSttsCd,
}),
[filters, subFilters],
);
const statisticsVerificationReportQuery =
useStatsVrfcRptData(submittedFilters);
const statisticsVerificationReportQuery = useStatsVrfcRptData(filters);
const statisticsVerificationResultQuery =
useStatsVrfcRsltData(submittedParams);
useStatsVrfcRsltData(detailParams);
const statisticsVerificationReportData =
statisticsVerificationReportQuery.data ?? [];
@@ -253,11 +264,17 @@ function StatisticsVerificationReport() {
const nextRows = statisticsVerificationResultGridRows;
const prevLength = prevLengthRef.current;
if (!dataProvider || nextRows.length === 0) {
if (!dataProvider) {
prevLengthRef.current = nextRows.length;
return;
}
if (nextRows.length === 0) {
dataProvider.setRows?.([]);
prevLengthRef.current = 0;
return;
}
if (nextRows.length > prevLength && prevLength > 0) {
// 무한스크롤 시 데이터 처리
const newRows = nextRows.slice(prevLength);
@@ -327,17 +344,16 @@ function StatisticsVerificationReport() {
statisticsVerificationReportQuery.isError,
]);
function handleSearch(
nextFilters: StatisticsVerificationFilterState,
nextSubFilters: StatisticsVerificationSubFilterState,
) {
setSelectedReportRows([]);
function resetResultState() {
setSelectedResultRows([]);
setSubmittedFilters({ ...nextFilters });
setSubmittedParams({
main: { ...nextFilters },
sub: { ...nextSubFilters },
});
editedResultRowMapRef.current = new Map();
prevLengthRef.current = 0;
resultGridRef.current?.dataProvider?.setRows?.([]);
}
function resetReportState() {
setSelectedReportRows([]);
resetResultState();
}
async function handleLoadMoreDetail() {
@@ -350,14 +366,6 @@ function StatisticsVerificationReport() {
}
}
function handleResultSearch() {
setSelectedResultRows([]);
setSubmittedParams({
main: { ...filters },
sub: { ...subFilters },
});
}
function handleFilterChange<
K extends keyof StatisticsVerificationFilterState,
>(key: K) {
@@ -365,6 +373,7 @@ function StatisticsVerificationReport() {
value?: StatisticsVerificationFilterState[K],
optionData?: { label: string; value: string },
) => {
resetReportState();
if (key === "sewerBzmnCd") {
setFilters((prev) => ({
...prev,
@@ -372,8 +381,17 @@ function StatisticsVerificationReport() {
sewerBzmnCd: optionData?.value,
sggNm: "",
}));
setSubFilters((prev) => ({
...prev,
ctpvNm: "",
sggNm: "",
sewerBzmnCd: "",
}));
return;
}
if (key === "statsCd") {
setSubFilters((prev) => ({ ...prev, statsCd: "" }));
}
setFilters((prev) => ({ ...prev, [key]: value }));
};
}
@@ -382,10 +400,16 @@ function StatisticsVerificationReport() {
K extends keyof StatisticsVerificationSubFilterState,
>(key: K) {
return (value?: StatisticsVerificationSubFilterState[K]) => {
resetResultState();
setSubFilters((prev) => ({ ...prev, [key]: value }));
};
}
function handleSubFilterReset() {
setSubFilters(DEFAULT_SUB_FILTERS);
resetResultState();
}
function handleCellEdited(params: CellEditedCallbackParams) {
const { itemIndex, fieldName, newValue } = params;
const dataProvider = resultGridRef.current?.dataProvider as
@@ -403,11 +427,7 @@ function StatisticsVerificationReport() {
function handleReset() {
setFilters(DEFAULT_FILTERS);
setSubFilters(DEFAULT_SUB_FILTERS);
setSelectedReportRows([]);
setSelectedResultRows([]);
editedResultRowMapRef.current = new Map();
prevLengthRef.current = 0;
handleSearch(DEFAULT_FILTERS, DEFAULT_SUB_FILTERS);
resetReportState();
}
function handleApprove() {
@@ -532,9 +552,6 @@ function StatisticsVerificationReport() {
<Button color="white" onClick={handleReset}>
초기화
</Button>
<Button onClick={() => handleSearch(filters, subFilters)}>
조회
</Button>
<Button
color="green"
onClick={handleApprove}
@@ -557,6 +574,15 @@ function StatisticsVerificationReport() {
setRowData={() => {}}
showHeaderCheckbox={true}
gridHeight={30}
onRowClick={(row: StatsInputStatusList) => {
setSubFilters((prev) => ({
...prev,
ctpvNm: row?.ctpvNm ?? "",
sggNm: row?.sggNm ?? "",
sewerBzmnCd: row?.sewerBzmnCd ?? "",
statsCd: row?.statsCd ?? "",
}));
}}
/>
<h3 className="mt-10 px-2 text-[1.8rem] font-bold text-title">
@@ -578,14 +604,15 @@ function StatisticsVerificationReport() {
/>
</div>
<div className="flex items-end justify-end gap-2">
<Button onClick={handleResultSearch}>조회</Button>
<Button color="white" onClick={handleSubFilterReset}>
초기화
</Button>
<Button
color="red"
onClick={handleReject}
disabled={
selectedResultRows.length === 0
// ||
// selectedResultRows.some((row) => row.prcsYn !== "2")
selectedResultRows.length === 0 ||
selectedResultRows.some((row) => row.prcsYn !== "2")
}
>
반려
@@ -65,21 +65,15 @@ const Indicators: React.FC<IndicatorsProps> = ({ anlsSn, vrfcTrgtSn }) => {
return (
<div className="card card-border !m-0 grid min-h-[10rem] grid-cols-4 gap-2">
<IndicatorItem label="r 중위수" value={dtl?.rtCnterVl} />
<IndicatorItem label="R 중위수" value={dtl?.rtCnterVl} />
<IndicatorItem label="E 중위수" value={dtl?.lastTrsfScrCnterVl} />
<IndicatorItem label="하위 기준값" value={dtl?.lastTrsfScrLlmtVl} />
<IndicatorItem
label="(E_m)E 중위수"
value={dtl?.lastTrsfScrCnterVl}
/>
<IndicatorItem
label="(E_l)E 하한값"
value={dtl?.lastTrsfScrLlmtVl}
/>
<IndicatorItem
label="(E_u)E 상한값"
label="상위 기준값"
value={dtl?.lastTrsfScrUplmtVl}
/>
<IndicatorItem label="E_D_L" value={dtl?.llmtVl} />
<IndicatorItem label="E_D_U" value={dtl?.uplmtVl} />
<IndicatorItem label="이상치 하한값" value={dtl?.llmtVl} />
<IndicatorItem label="이상치 상한값" value={dtl?.uplmtVl} />
<IndicatorItem label="이상치 하한선" value={dtl?.otlrLlmtLine} />
<IndicatorItem label="이상치 상한선" value={dtl?.otlrUplmtLine} />
</div>
@@ -46,11 +46,6 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
selectedRowId,
appliedFilter,
}) => {
const [selectedSido, setSelectedSido] = useState<string>("");
const [selectedGugun, setSelectedGugun] = useState<string>("");
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [showAllOnEmptyFilter, setShowAllOnEmptyFilter] =
useState<boolean>(true);
const [isDownloading, setIsDownloading] = useState<boolean>(false);
const [gridRowData, setGridRowData] = useState<
@@ -58,7 +53,7 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
>([]);
const { data: sidoData } = useStAnlsSidoCombo();
const { data: gugunData } = useStAnlsSigunguCombo(selectedSido);
const { data: gugunData } = useStAnlsSigunguCombo(appliedFilter.sido ?? "");
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
@@ -69,14 +64,7 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
const rowData = useMemo(() => {
let data = (resultData?.content ?? []) as StAnlsRsltResponse[];
if (
!appliedFilter.sido &&
!appliedFilter.gugun &&
!appliedFilter.status
) {
data = showAllOnEmptyFilter ? data : [];
} else {
{
const sidoCd = appliedFilter.sido;
if (sidoCd) {
data = data.filter((item) =>
@@ -116,32 +104,18 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
}));
return mappedData;
}, [resultData, appliedFilter, showAllOnEmptyFilter]);
}, [resultData, appliedFilter]);
const handleSidoChange = (val: string) => {
setSelectedSido(val);
setSelectedGugun("");
};
const handleSearch = () => {
onFilterChange({
sido: selectedSido,
gugun: selectedGugun,
status: selectedStatus,
});
setShowAllOnEmptyFilter(false);
onFilterChange({ ...appliedFilter, sido: val, gugun: "" });
};
const handleReset = () => {
setSelectedSido("");
setSelectedGugun("");
setSelectedStatus("");
onFilterChange({
sido: "",
gugun: "",
status: "",
});
setShowAllOnEmptyFilter(true);
};
const handleDownload = async () => {
@@ -182,7 +156,7 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
<div className="flex justify-between">
<div className="flex items-center gap-2">
<Select
value={selectedSido || ""}
value={appliedFilter.sido || ""}
onValueChange={handleSidoChange}
>
<SelectTrigger className="w-[12rem] bg-white">
@@ -197,9 +171,11 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
</SelectContent>
</Select>
<Select
value={selectedGugun}
onValueChange={setSelectedGugun}
disabled={!selectedSido}
value={appliedFilter.gugun || ""}
onValueChange={(val) =>
onFilterChange({ ...appliedFilter, gugun: val })
}
disabled={!appliedFilter.sido}
>
<SelectTrigger className="w-[12rem] bg-white">
<SelectValue placeholder="시군구 선택" />
@@ -213,8 +189,10 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
</SelectContent>
</Select>
<Select
value={selectedStatus}
onValueChange={setSelectedStatus}
value={appliedFilter.status || ""}
onValueChange={(val) =>
onFilterChange({ ...appliedFilter, status: val })
}
>
<SelectTrigger className="w-[10rem] bg-white">
<SelectValue placeholder="상태 선택" />
@@ -231,7 +209,7 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
<Button color="white" onClick={handleReset}>
초기화
</Button>
<Button onClick={handleSearch}>조회</Button>
{/* <Button onClick={handleSearch}>조회</Button> */}
<Button
color="green"
onClick={handleDownload}
@@ -104,10 +104,12 @@ const ScatterChart: React.FC<ScatterChartProps> = ({
anlsSn,
vrfcTrgtSn,
);
console.log("resultData: ", resultData);
const { data: chartData, isLoading: isLoadingChart } = useStAnlsChartData(
anlsSn,
vrfcTrgtSn,
);
console.log("chartData: ", chartData);
const isLoading = isLoadingGrid || isLoadingChart;
@@ -158,10 +160,12 @@ const ScatterChart: React.FC<ScatterChartProps> = ({
item,
};
if (item.otlrYnCd === "0") {
normalPoints.push(pt);
} else {
if (item.otlrYnCd === "2") {
return;
} else if (item.otlrYnCd === "1") {
outlierPoints.push(pt);
} else {
normalPoints.push(pt);
}
});
@@ -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,
};
+19 -22
View File
@@ -14,11 +14,6 @@ import { stAnlsRsltApi } from "./statistics-analysis-result-api";
const DETAIL_PAGE_SIZE = 100;
interface StatisticsVerificationDetailSubmittedParams {
main: StatisticsVerificationFilterState;
sub: StatisticsVerificationSubFilterState;
}
export const useStAnlsSidoCombo = () => {
return useQuery({
queryKey: [ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST, "sido"],
@@ -102,23 +97,24 @@ export const useStatsVrfcRptData = (
};
export const useStatsVrfcRsltData = (
submittedDetailParams: StatisticsVerificationDetailSubmittedParams | null,
params: StatisticsVerificationSubFilterState | null,
) => {
return useInfiniteQuery({
queryKey: [
ENDPOINT.ST_SERVICE.INPUTVERIFY,
"verify-results",
submittedDetailParams?.main.statsYr ?? "",
submittedDetailParams?.main.ctpvNm ?? "",
submittedDetailParams?.main.sggNm ?? "",
submittedDetailParams?.main.sewerBzmnCd ?? "",
submittedDetailParams?.main.statsCd ?? "",
submittedDetailParams?.sub.prcsYnList ?? "",
submittedDetailParams?.sub.pbcpAprvYn ?? "",
params?.statsYr ?? "",
params?.ctpvNm ?? "",
params?.sggNm ?? "",
params?.sewerBzmnCd ?? "",
params?.statsCd ?? "",
params?.prcsYnList ?? "",
params?.pbcpAprvYn ?? "",
params?.laeSbmsnSttsCd ?? "",
],
initialPageParam: 1,
queryFn: async ({ pageParam }) => {
if (!submittedDetailParams)
if (!params)
return {
verifyResultList: [],
pageInfo: {
@@ -134,13 +130,14 @@ export const useStatsVrfcRsltData = (
page: pageParam,
size: DETAIL_PAGE_SIZE,
searchDto: {
statsYr: submittedDetailParams.main.statsYr,
ctpvNm: submittedDetailParams.main.ctpvNm,
sggNm: submittedDetailParams.main.sggNm,
sewerBzmnCd: submittedDetailParams.main.sewerBzmnCd,
statsCd: submittedDetailParams.main.statsCd,
prcsYnList: submittedDetailParams.sub.prcsYnList,
pbcpAprvYn: submittedDetailParams.sub.pbcpAprvYn,
statsYr: params.statsYr,
ctpvNm: params.ctpvNm,
sggNm: params.sggNm,
sewerBzmnCd: params.sewerBzmnCd,
statsCd: params.statsCd,
prcsYnList: params.prcsYnList,
pbcpAprvYn: params.pbcpAprvYn,
laeSbmsnSttsCd: params.laeSbmsnSttsCd,
},
});
@@ -153,7 +150,7 @@ export const useStatsVrfcRsltData = (
if (currentPage + 1 <= totalPages) return currentPage + 1;
return undefined;
},
enabled: Boolean(submittedDetailParams),
enabled: Boolean(params),
refetchOnWindowFocus: false,
});
};
+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>>;
}
@@ -196,11 +196,12 @@ export interface CopyVerifyDataRequest {
}
export interface VerifyResultSearchRequest {
sewerBzmnCd?: string | null;
ctpvNm?: string | null;
sggNm?: string | null;
sewerBzmnCd?: string | null;
statsYr: string;
statsCd?: string | null;
laeSbmsnSttsCd?: string | null;
vrfcMthdCd?: string;
prcsYnList?: string; // 검증 상태 리스트 (0: 미검증, 1: 수정완료, 2:예외통과, 3: 반려)
pbcpAprvYn?: string; // 공단 승인 여부 (Y/N)
@@ -6,6 +6,7 @@ export interface StatsInputStatusListRequestParams {
sewerBzmnCd?: string | null;
statsYr: string;
statsCd?: string | null;
laeSbmsnSttsCd?: string | null;
}
export interface StatsInputStatusList {
@@ -47,7 +47,7 @@ export const regionalGridColumnInfo: ColumnInfo = {
{
name: "chgRt",
fieldName: "chgRt",
header: { text: "R" },
header: { text: "전년대비 비율" },
width: 60,
valueType: ValueType.NUMBER,
dataFormat: "#,##0.00",
@@ -55,7 +55,7 @@ export const regionalGridColumnInfo: ColumnInfo = {
{
name: "trsfRt",
fieldName: "trsfRt",
header: { text: "S" },
header: { text: "S_i" },
width: 60,
valueType: ValueType.NUMBER,
dataFormat: "#,##0.00",
@@ -63,7 +63,7 @@ export const regionalGridColumnInfo: ColumnInfo = {
{
name: "otlrTrsfRt",
fieldName: "otlrTrsfRt",
header: { text: "E" },
header: { text: "기준값" },
width: 60,
valueType: ValueType.NUMBER,
dataFormat: "#,##0.00",
@@ -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 })),
}));
};