Files
frontend/apps/sn/src/features/exmn/ui/exmn-registration-page.tsx
T

709 lines
23 KiB
TypeScript

"use client";
import { getSnRowNumber } from "@/shared/lib/row-number";
import LoadingOverlay from "@/components/ui/loading-bar";
import { fileDownload } from "@/components/lib/utils";
import { getStartYearArray } from "@/components/ui/year-range-select-box";
import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
import useStore from "@/entities/reference-data/model/reference-data-store";
import {
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import {
STANDARD_ROW_NUMBER_HEADER,
StandardDataTable,
StandardTableSortHeader,
type UiTableSort,
} from "@common/business_components";
import {
getCoreRowModel,
useReactTable,
type ColumnDef,
type PaginationState,
} from "@tanstack/react-table";
import { useEffect, useMemo, useRef, useState } from "react";
import {
downloadExmnBusinessReport,
getExmnBusinesses,
} from "../api/exmn-business-api";
import type {
ExmnBusiness,
ExmnBusinessSearchParams,
} from "../model/exmn-business-types";
import { ExmnStatusDetailPanel } from "./exmn-status-detail-panel";
import { EXMN_TABLE_HEADER_CLASS } from "./exmn-standard-data-table";
const BUSINESS_LIST_PAGE_SIZE = 10;
const BUSINESS_TABLE_HEAD = [
{ id: "rowNumber", title: STANDARD_ROW_NUMBER_HEADER, sortKey: "exmnBizId" },
{ id: "bizCyclText", title: "사업차수", sortKey: "bizCycl" },
{ id: "bizYr", title: "사업년도", sortKey: "bizYr" },
{ id: "ctpvNm", title: "시도", sortKey: "ctpvNm" },
{ id: "sggNm", title: "시군구", sortKey: "sggNm" },
{ id: "bizNm", title: "사업명", sortKey: "bizNm" },
{ id: "trtdstCntText", title: "처리구역·분구(개소)", sortKey: "trtdstCnt" },
{
id: "prcisExmnPrlgText",
title: "정밀조사 연장(km)",
sortKey: "prcisExmnPrlg",
},
// 첨부서비스에서 현재 페이지 파일명을 보강하므로 전체 결과 파일명 정렬은 지원하지 않는다.
{ id: "reportYnText", title: "보고서", sortKey: null },
];
interface BusinessFilters {
bizCycl: string;
bizYr: string;
ctpvCd: string;
sggCd: string;
ctpvNm: string;
sggNm: string;
bizNm: string;
}
const EMPTY_FILTERS: BusinessFilters = {
bizCycl: "",
bizYr: "0",
ctpvCd: "0",
sggCd: "0",
ctpvNm: "",
sggNm: "",
bizNm: "",
};
type Notice = { type: "success" | "error" | "info"; text: string } | null;
/** 사업정보 조회·등록과 선택 사업의 조사결과 등록 진입을 제공하는 SFR-010-004 메인 화면이다. */
export function ExmnRegistrationPage() {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
/** React 개발 모드의 effect 재실행에서도 최초 목록 조회를 한 번만 수행한다. */
const initialBusinessesLoadedRef = useRef(false);
// 입력 중 조건과 실제 서버 조회 조건을 분리해 조회·초기화 동작을 명확히 유지한다.
const [draftFilters, setDraftFilters] =
useState<BusinessFilters>(EMPTY_FILTERS);
const [appliedFilters, setAppliedFilters] =
useState<BusinessFilters>(EMPTY_FILTERS);
const [businesses, setBusinesses] = useState<ExmnBusiness[]>([]);
const [totalElements, setTotalElements] = useState(0);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: BUSINESS_LIST_PAGE_SIZE,
});
// 표시명 컬럼도 같은 이름 필드를 사용하는 서버 단일 정렬 상태다.
const [sort, setSort] = useState<UiTableSort>();
// 선택 사업 ID는 서버 재조회 뒤에도 같은 행을 복원할 수 있도록 객체와 별도로 유지한다.
const [selectedBusinessId, setSelectedBusinessId] = useState<string | null>(
null,
);
// 목록 행을 선택한 뒤 스크롤과 키보드 포커스를 함께 이동할 상세현황 영역이다.
const detailPanelRef = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
/** 팝업과 동일한 행정구역 공통코드를 검색 Select에 준비한다. */
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
const availableSgg = useMemo(() => {
if (draftFilters.ctpvCd === "0") return sgg;
return sgg.filter((option) =>
String(option.val).startsWith(draftFilters.ctpvCd.slice(0, 2)),
);
}, [draftFilters.ctpvCd, sgg]);
const selectedBusiness = useMemo(
() =>
businesses.find(
(business) => business.exmnBizId === selectedBusinessId,
) ?? null,
[businesses, selectedBusinessId],
);
/** 목록 행 선택 후 렌더링된 상세현황을 화면 상단으로 이동하고 포커스를 부여한다. */
const handleBusinessRowClick = (business: ExmnBusiness) => {
setSelectedBusinessId(business.exmnBizId);
window.requestAnimationFrame(() => {
const detailPanel = detailPanelRef.current;
if (!detailPanel) return;
detailPanel.scrollIntoView({ behavior: "smooth", block: "start" });
detailPanel.focus({ preventScroll: true });
});
};
/** 사업 목록의 숫자·행정구역·사업보고서 파일명을 표시 전용 값으로 변환한다. */
const businessRows = useMemo(
() =>
businesses.map((business, index) => ({
...business,
rowNumber: getSnRowNumber({
total: totalElements,
rowIndex: index,
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
}),
bizCyclText: business.bizCycl == null ? "-" : `${business.bizCycl}차`,
trtdstCntText: Number(business.trtdstCnt ?? 0).toLocaleString("ko-KR"),
prcisExmnPrlgText: Number(business.prcisExmnPrlg ?? 0).toLocaleString(
"ko-KR",
{ maximumFractionDigits: 3 },
),
reportYnText:
business.bizRptpFileName?.trim() ||
(business.reportYn === "Y" || business.bizRptpAtchFileId
? "있음"
: "없음"),
})),
[businesses, pagination.pageIndex, pagination.pageSize, totalElements],
);
/** 화면 조건과 현재 정렬을 0-base 서버 Page 계약으로 변환한다. */
const buildSearchParams = (
filters: BusinessFilters,
pageIndex: number,
nextSort?: UiTableSort,
): ExmnBusinessSearchParams => ({
bizCycl: filters.bizCycl || undefined,
bizYr: filters.bizYr !== "0" ? filters.bizYr : undefined,
ctpvNm: filters.ctpvNm || undefined,
sggNm: filters.sggNm || undefined,
bizNm: filters.bizNm || undefined,
page: pageIndex,
size: BUSINESS_LIST_PAGE_SIZE,
sort: nextSort ? `${nextSort.key},${nextSort.direction}` : undefined,
});
/** 지정 조회조건·정렬·페이지로 사업 목록을 읽고 현재 페이지 선택을 복원한다. */
const fetchBusinesses = async (
filters: BusinessFilters,
pageIndex = 0,
nextSort: UiTableSort | undefined = sort,
) => {
setIsLoading(true);
setNotice(null);
try {
const result = await getExmnBusinesses(
buildSearchParams(filters, pageIndex, nextSort),
);
const content = result.content;
setBusinesses(content);
setTotalElements(result.totalElements);
setSelectedBusinessId((current) =>
content.some((item) => item.exmnBizId === current) ? current : null,
);
setPagination((current) => ({
...current,
pageIndex: result.number,
}));
return true;
} catch (error) {
setBusinesses([]);
setTotalElements(0);
setSelectedBusinessId(null);
setNotice({
type: "error",
text:
error instanceof Error
? error.message
: "조사사업 목록 조회 중 오류가 발생했습니다.",
});
return false;
} finally {
setIsLoading(false);
}
};
/** 화면 최초 진입 시 빈 조회조건으로 실제 조사사업 목록을 조회한다. */
useEffect(() => {
if (initialBusinessesLoadedRef.current) return;
initialBusinessesLoadedRef.current = true;
void fetchBusinesses(EMPTY_FILTERS, 0, undefined);
// 최초 진입 시점의 빈 조회조건으로 한 번만 실행한다.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/** 입력 중 조건을 확정하고 첫 페이지를 조회한다. */
const handleSearch = async () => {
setAppliedFilters(draftFilters);
await fetchBusinesses(draftFilters, 0, sort);
};
/** CS 초기화 버튼으로 모든 검색조건과 페이지를 최초 조회 기준으로 복원한다. */
const handleReset = async () => {
setDraftFilters(EMPTY_FILTERS);
setAppliedFilters(EMPTY_FILTERS);
setSelectedBusinessId(null);
setSort(undefined);
await fetchBusinesses(EMPTY_FILTERS, 0, undefined);
};
/** 목록의 보고서 링크에서 첨부서비스 원본 PDF를 내려받는다. */
const handleReportDownload = async (business: ExmnBusiness) => {
const attachmentId = business.bizRptpAtchFileId?.trim();
if (!attachmentId) {
setNotice({
type: "error",
text: "다운로드할 사업보고서 첨부파일 정보가 없습니다.",
});
return;
}
setIsLoading(true);
setNotice(null);
try {
const reportBlob = await downloadExmnBusinessReport(attachmentId);
fileDownload(
reportBlob,
business.bizRptpFileName?.trim() || `${business.bizNm}_사업보고서.pdf`,
);
} catch (error) {
setNotice({
type: "error",
text:
error instanceof Error
? error.message
: "사업보고서 다운로드 중 오류가 발생했습니다.",
});
} finally {
setIsLoading(false);
}
};
/** 공통 WindowPopup 체계로 사업정보 등록 화면을 연다. */
const openBusinessRegister = () => {
setNotice(null);
const popup = openWindowPopup("exmnBusinessRegister", {
sourcePath: window.location.pathname,
});
if (!popup) {
setNotice({
type: "error",
text: "조사사업 등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.",
});
}
};
/** 선택 사업을 기존 등록 팝업의 수정 모드로 열어 기본정보·처리분구·양식을 다시 관리한다. */
const openBusinessUpdate = () => {
if (!selectedBusiness) {
setNotice({
type: "error",
text: "사업 목록에서 수정할 사업을 먼저 선택해주세요.",
});
return;
}
setNotice(null);
const popup = openWindowPopup("exmnBusinessRegister", {
exmnBizId: selectedBusiness.exmnBizId,
sourcePath: window.location.pathname,
});
if (!popup) {
setNotice({
type: "error",
text: "사업수정 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.",
});
}
};
/** 선택 사업 컨텍스트를 전달해 공통 WindowPopup 결과등록 화면을 연다. */
const openResultRegister = () => {
if (!selectedBusiness) {
setNotice({
type: "error",
text: "사업 목록에서 대상 사업을 먼저 선택해주세요.",
});
return;
}
setNotice(null);
const popup = openWindowPopup("exmnResultRegister", {
exmnBizId: selectedBusiness.exmnBizId,
bizYr: selectedBusiness.bizYr,
bizCycl: selectedBusiness.bizCycl ?? 0,
bizNm: selectedBusiness.bizNm,
ctpvNm: selectedBusiness.ctpvNm,
sggNm: selectedBusiness.sggNm,
trtdstCnt: selectedBusiness.trtdstCnt,
prcisExmnPrlg: selectedBusiness.prcisExmnPrlg,
tpco: selectedBusiness.tpco,
reportYn: selectedBusiness.reportYn ?? "N",
sourcePath: window.location.pathname,
});
if (!popup) {
setNotice({
type: "error",
text: "결과등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.",
});
}
};
/** WindowPopup의 등록·저장 완료 메시지를 받아 부모 목록과 안내 상태를 동기화한다. */
useEffect(() => {
const handlePopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (
event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_CREATED
) {
setSelectedBusinessId(null);
void fetchBusinesses(appliedFilters, 0, sort).then((refreshed) => {
if (refreshed)
setNotice({ type: "success", text: "조사사업이 등록되었습니다." });
});
return;
}
if (
event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_UPDATED
) {
void fetchBusinesses(
appliedFilters,
pagination.pageIndex,
sort,
).then((refreshed) => {
if (refreshed)
setNotice({ type: "success", text: "조사사업이 수정되었습니다." });
});
return;
}
if (
event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED
) {
void fetchBusinesses(
appliedFilters,
pagination.pageIndex,
sort,
).then((refreshed) => {
if (refreshed)
setNotice({
type: "success",
text: "조사결과 저장을 완료했습니다.",
});
});
}
};
window.addEventListener("message", handlePopupMessage);
return () => window.removeEventListener("message", handlePopupMessage);
}, [appliedFilters, pagination.pageIndex, sort]);
/** 정렬 변경 시 첫 페이지와 선택 사업을 초기화하고 서버 전체 결과를 다시 조회한다. */
const handleBusinessSortChange = (nextSort?: UiTableSort) => {
setSort(nextSort);
setSelectedBusinessId(null);
void fetchBusinesses(appliedFilters, 0, nextSort);
};
/** 보고서 다운로드와 행 선택을 유지하며 조사사업 목록을 공통 DataTable 열로 변환한다. */
const columns = useMemo<ColumnDef<(typeof businessRows)[number]>[]>(
() =>
BUSINESS_TABLE_HEAD.map((head) => ({
id: head.id,
accessorFn: (row) => row[head.id as keyof typeof row],
header: () =>
head.sortKey === null ? (
head.title
) : (
<StandardTableSortHeader
label={head.title}
sortKey={head.sortKey}
sort={sort}
onSortChange={handleBusinessSortChange}
/>
),
cell: ({ row, getValue }) => {
const business = row.original;
const isReportCell = head.id === "reportYnText";
const reportAvailable =
isReportCell && Boolean(business.bizRptpAtchFileId?.trim());
const value = String(getValue() ?? "");
if (reportAvailable) {
return (
<button
type="button"
className="hover:text-primary/80 block w-full truncate font-semibold text-primary underline underline-offset-4"
aria-label={`${business.reportYnText} 다운로드`}
title={`${business.reportYnText} 다운로드`}
onClick={(event) => {
event.stopPropagation();
void handleReportDownload(business);
}}
>
{value}
</button>
);
}
return (
<span
className="block min-w-0 whitespace-normal break-words"
title={value}
>
{value}
</span>
);
},
meta: {
width:
head.id === "bizNm"
? 240
: head.id === "reportYnText"
? 170
: head.id === "trtdstCntText" || head.id === "prcisExmnPrlgText"
? 125
: head.id === "rowNumber"
? 60
: 90,
align: "center" as const,
},
})),
[appliedFilters, sort],
);
const table = useReactTable({
data: businessRows,
columns,
state: {
pagination,
rowSelection: selectedBusinessId ? { [selectedBusinessId]: true } : {},
},
getRowId: (row) => row.exmnBizId,
rowCount: totalElements,
manualPagination: true,
onPaginationChange: (updater) => {
const next =
typeof updater === "function" ? updater(pagination) : updater;
if (next.pageIndex === pagination.pageIndex) return;
setSelectedBusinessId(null);
void fetchBusinesses(appliedFilters, next.pageIndex, sort);
},
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="sn-exmn-registration-page flex min-h-0 flex-col">
<LoadingOverlay isLoading={isLoading} />
<div className="card card-border !mt-6">
<form
className="flex flex-wrap items-center gap-2 p-2"
onSubmit={(event) => {
event.preventDefault();
void handleSearch();
}}
>
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">
사업차수
</Label>
<Input
type="number"
min={0}
className="w-[8rem]"
value={draftFilters.bizCycl}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
bizCycl: event.target.value,
}))
}
/>
</div>
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">
사업년도
</Label>
<Select
value={draftFilters.bizYr}
onValueChange={(value) =>
setDraftFilters((current) => ({
...current,
bizYr: String(value),
}))
}
>
<SelectTrigger className="w-[11rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{getStartYearArray({ placeholder: "전체" }).map((option) => (
<SelectItem
key={String(option.val)}
value={String(option.val)}
>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">시도</Label>
<Select
value={draftFilters.ctpvCd}
onValueChange={(value) => {
const ctpvCd = String(value);
const selected = ctpv.find(
(option) => String(option.val) === ctpvCd,
);
setDraftFilters((current) => ({
...current,
ctpvCd,
ctpvNm: ctpvCd === "0" ? "" : (selected?.text ?? ""),
sggCd: "0",
sggNm: "",
}));
}}
>
<SelectTrigger className="w-[14rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[{ val: "0", text: "전체" }, ...ctpv].map((option) => (
<SelectItem
key={String(option.val)}
value={String(option.val)}
>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">시군구</Label>
<Select
value={draftFilters.sggCd}
onValueChange={(value) => {
const sggCd = String(value);
const selected = availableSgg.find(
(option) => String(option.val) === sggCd,
);
setDraftFilters((current) => ({
...current,
sggCd,
sggNm: sggCd === "0" ? "" : (selected?.text ?? ""),
}));
}}
>
<SelectTrigger className="w-[14rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[{ val: "0", text: "전체" }, ...availableSgg].map((option) => (
<SelectItem
key={String(option.val)}
value={String(option.val)}
>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">사업명</Label>
<Input
className="w-[24rem]"
value={draftFilters.bizNm}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
bizNm: event.target.value,
}))
}
/>
</div>
<Button type="submit" className="w-[7.4rem]" disabled={isLoading}>
조회
</Button>
<Button
type="button"
color="white"
className="w-[8.8rem]"
disabled={isLoading}
onClick={() => void handleReset()}
>
초기화
</Button>
</form>
</div>
{notice && (
<div
role="alert"
className={`mx-[1.2rem] mt-4 rounded border px-4 py-3 text-sm ${
notice.type === "error"
? "border-red-200 bg-red-50 text-red-700"
: notice.type === "info"
? "border-primary bg-primary-transparent text-primary"
: "border-emerald-200 bg-emerald-50 text-emerald-700"
}`}
>
{notice.text}
</div>
)}
<div className="mt-4 min-w-0">
<StandardDataTable
table={table}
title="조사사업 목록"
className={EXMN_TABLE_HEADER_CLASS}
scrollMode="horizontal"
minTableWidth="110rem"
onRowClick={handleBusinessRowClick}
actions={
<>
<Button
type="button"
color="primary"
disabled={isLoading}
onClick={openBusinessRegister}
>
조사사업등록
</Button>
<Button
type="button"
color="primary"
disabled={isLoading}
onClick={openResultRegister}
>
결과등록
</Button>
<Button
type="button"
color="primary"
disabled={isLoading || !selectedBusiness}
onClick={openBusinessUpdate}
>
수정
</Button>
</>
}
/>
</div>
<div
ref={detailPanelRef}
tabIndex={-1}
aria-label="조사 상세현황"
className="scroll-mt-[1.2rem] focus:outline-none"
>
<ExmnStatusDetailPanel business={selectedBusiness} />
</div>
</div>
);
}