조사사업 보고서 다운로드 및 상세현황 그리드 보정

This commit is contained in:
kiminchan
2026-07-23 17:26:08 +09:00
parent 477426c384
commit c1d6cd7b8e
5 changed files with 122 additions and 21 deletions
@@ -53,6 +53,7 @@
| 2026-07-23 | 조사현황 4차 클릭 구조 교체 | 목록을 공통 Table 스타일의 화면 전용 표로 교체하고 모든 데이터 셀을 전체 면적 button으로 구성, 공통 `rowClick` 제거 |
| 2026-07-23 | 조사관리 상세현황 사용자 최종 확인 | 전체 행 선택, 관로·맨홀 탭, 목록·상세 배치와 상세 집계 표시 확인 완료. 조사관리 상세현황 화면 단계 완료 판정 |
| 2026-07-23 | git status·문서 정합성 점검 | 추적 코드 8개·신규 코드 15개와 FE 문서 4개를 대조하고 작업카드·구현·검증 문서의 단계 상태와 실제 파일 목록을 동기화 |
| 2026-07-23 | 사업보고서 다운로드 정적 확인 | 첨부 ID가 있는 보고서 셀의 파란 밑줄 링크, 행 클릭 전파 차단, 기존 첨부서비스 Blob 다운로드, 샘플 미연결 안내 확인 |
## 3. FE-0 검증 결과
@@ -161,6 +162,15 @@
- 사용자의 로그인된 실제 화면 최종 확인으로 행 전체 선택, 관로·맨홀 전환, 목록·상세 배치와 상세 집계 표시를 완료 판정했다.
- 남은 검증은 실 API/백엔드 연동과 후속 상세보기·다운로드 구현이다. 지도보기는 보류다.
## 11. 조사사업 목록 사업보고서 다운로드 검증 결과
- `bizRptpAtchFileId`가 있는 행만 보고서 텍스트에 파란색·밑줄·hover 스타일을 적용했다.
- 보고서 링크 클릭 시 `stopPropagation()`으로 조사사업 행 선택 동작과 파일 다운로드 동작을 분리했다.
- 기존 첨부서비스 경로 `/attach-service/api/v1/download/{atflId}`와 공통 `fileDownload()`를 재사용했다.
- 실제 파일명 원천이 목록 응답에 없으므로 현재 다운로드명은 `${사업명}_사업보고서.pdf`다.
- `sample=Y`는 실제 첨부서비스를 호출하지 않고 안내를 표시한다.
- 실제 첨부 ID를 사용한 브라우저 다운로드와 PDF 열림 확인은 백엔드·첨부 데이터 준비 후 수행해야 한다.
## 7. 흡수한 기존 문서
- 없음
@@ -267,6 +267,15 @@
- 사업등록 popup은 확정 크기 `920×620`, 처리분구 영역 `21rem`으로 복구했다. 결과등록은 `980×340`을 유지한다.
- 따라서 조사사업 목록·상세의 폭 정렬은 유지하면서 WindowPopup의 기존 확정 디자인에는 전역 CSS 변경이 전파되지 않는다.
### 9.15 조사사업 목록 사업보고서 직접 다운로드
- 정보검색 보고서 탭의 파란 밑줄 파일 링크와 기존 운영 화면의 첨부서비스 Blob 다운로드 패턴을 재사용했다.
- 조사사업 목록에서 `bizRptpAtchFileId`가 있는 `보고서=있음` 셀만 파란색·밑줄·hover 색상으로 표시한다.
- 링크 클릭은 행 선택 이벤트의 전파를 중단하고 `/attach-service/api/v1/download/{atflId}`에서 원본 Blob을 받은 뒤 `${사업명}_사업보고서.pdf`로 내려받는다.
- `reportYn=Y`여도 첨부파일 ID가 없으면 다운로드 링크로 표시하지 않는다.
- `sample=Y`의 샘플 첨부 ID는 실제 파일로 위장하지 않고 다운로드 미지원 안내만 표시한다.
- 신규 백엔드 API·DTO·DB 변경은 없으며 결과등록에서 저장한 `bizRptpAtchFileId`를 그대로 사용한다.
## 10. 2026-07-23 git status 기준 실제 수정 파일
### 10.1 추적 코드 변경 9개
@@ -107,6 +107,10 @@ export const createExmnTemplates = async (
export const downloadExmnTemplates = async (exmnBizId: string): Promise<Blob> =>
getBlobData(`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/templates/download`);
/** 사업보고서 첨부파일 ID로 기존 첨부서비스의 원본 파일을 내려받는다. */
export const downloadExmnBusinessReport = async (attachmentId: string): Promise<Blob> =>
getBlobData(`/attach-service/api/v1/download/${encodeURIComponent(attachmentId)}`);
/** 사업 파일별 첨부서비스 키 쌍을 조사사업에 연결한다. */
export const updateExmnAttachments = async (
exmnBizId: string,
@@ -1,6 +1,7 @@
"use client";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { fileDownload } from "@/components/lib/utils";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import { getStartYearArray } from "@/components/ui/YearRangeSelectBox";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
@@ -20,7 +21,10 @@ import { SearchDiv, SelectBox } from "@common/business_components/ui";
import type { UiTableSort } from "@common/business_components/ui";
import ListDiv from "@common/business_components/ui/list_div";
import { useEffect, useMemo, useRef, useState } from "react";
import { getExmnBusinesses } from "../api/exmn-business-api";
import {
downloadExmnBusinessReport,
getExmnBusinesses,
} from "../api/exmn-business-api";
import type {
ExmnBusiness,
ExmnBusinessSearchParams,
@@ -66,7 +70,7 @@ const EMPTY_FILTERS: BusinessFilters = {
bizNm: "",
};
type Notice = { type: "success" | "error"; text: string } | null;
type Notice = { type: "success" | "error" | "info"; text: string } | null;
/** 서버·샘플 목록에 같은 비교 규칙을 적용해 정렬 헤더와 실제 행 순서를 일치시킨다. */
const sortBusinessList = (
@@ -273,6 +277,33 @@ export function ExmnRegistrationPage() {
await fetchBusinesses(draftFilters, sort);
};
/** 목록의 보고서 링크에서 첨부서비스 원본 PDF를 내려받는다. */
const handleReportDownload = async (business: ExmnBusiness) => {
const attachmentId = business.bizRptpAtchFileId?.trim();
if (!attachmentId) {
setNotice({ type: "error", text: "다운로드할 사업보고서 첨부파일 정보가 없습니다." });
return;
}
if (sampleMode) {
setNotice({ type: "info", text: "샘플 모드에서는 실제 사업보고서 파일을 다운로드하지 않습니다." });
return;
}
setIsLoading(true);
setNotice(null);
try {
const reportBlob = await downloadExmnBusinessReport(attachmentId);
fileDownload(reportBlob, `${business.bizNm}_사업보고서.pdf`);
} catch (error) {
setNotice({
type: "error",
text: error instanceof Error ? error.message : "사업보고서 다운로드 중 오류가 발생했습니다.",
});
} finally {
setIsLoading(false);
}
};
/** 공통 WindowPopup 체계로 사업정보 등록 화면을 연다. */
const openBusinessRegister = () => {
setNotice(null);
@@ -406,7 +437,18 @@ export function ExmnRegistrationPage() {
</SearchDiv>
{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" : "border-emerald-200 bg-emerald-50 text-emerald-700"}`}>{notice.text}</div>
<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-blue-200 bg-blue-50 text-blue-700"
: "border-emerald-200 bg-emerald-50 text-emerald-700"
}`}
>
{notice.text}
</div>
)}
<ListDiv className="mt-4 min-h-0 shrink-0 overflow-hidden">
@@ -422,16 +464,18 @@ export function ExmnRegistrationPage() {
</Button>
)}
</div>
<div className="relative" style={{ marginTop: "0.6rem" }}>
<div className="relative mx-[1.2rem]" style={{ marginTop: "0.6rem" }}>
<div
className="overflow-x-hidden overflow-y-auto"
style={{
maxHeight: "16rem",
paddingLeft: "0.6rem",
paddingRight: "0.6rem",
scrollbarGutter: "stable",
}}
>
<div
className="card !m-0"
style={{ paddingTop: 0, paddingBottom: 0 }}
>
<Table variant="secondary" className="table-fixed">
<colgroup>
{BUSINESS_TABLE_HEAD.map((head) => (
@@ -513,17 +557,36 @@ export function ExmnRegistrationPage() {
className="cursor-pointer"
onClick={() => setSelectedBusinessId(business.exmnBizId)}
>
{BUSINESS_TABLE_HEAD.map((head) => (
<TableCell key={head.id} className="!p-0">
<button
type="button"
className={`flex min-h-[4rem] w-full cursor-pointer items-center justify-center px-2 py-[1.2rem] text-[1.3rem] ${selected ? "font-bold" : "font-normal"}`}
onClick={() => setSelectedBusinessId(business.exmnBizId)}
>
{String(business[head.id as keyof typeof business] ?? "")}
</button>
</TableCell>
))}
{BUSINESS_TABLE_HEAD.map((head) => {
const isReportCell = head.id === "reportYnText";
const reportAvailable = isReportCell && Boolean(business.bizRptpAtchFileId?.trim());
return (
<TableCell key={head.id} className="!p-0">
<button
type="button"
className={`flex min-h-[4rem] w-full cursor-pointer items-center justify-center px-2 py-[1.2rem] text-[1.3rem] ${
reportAvailable
? "font-semibold text-blue-600 underline underline-offset-4 hover:text-blue-800"
: selected
? "font-bold"
: "font-normal"
}`}
aria-label={reportAvailable ? `${business.bizNm} 사업보고서 다운로드` : undefined}
title={reportAvailable ? "사업보고서 다운로드" : undefined}
onClick={(event) => {
if (reportAvailable) {
event.stopPropagation();
void handleReportDownload(business);
return;
}
setSelectedBusinessId(business.exmnBizId);
}}
>
{String(business[head.id as keyof typeof business] ?? "")}
</button>
</TableCell>
);
})}
</TableRow>
);
}) : (
@@ -535,6 +598,7 @@ export function ExmnRegistrationPage() {
)}
</TableBody>
</Table>
</div>
</div>
<span
aria-hidden="true"
@@ -22,6 +22,14 @@ import { getExmnSampleSectionSummaries } from "../model/exmn-sample-data";
const ACTION_BUTTON_CLASS =
"rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white";
// 목록 그리드와 같이 헤더 1행 + 본문 6행만 보이게 해 다음 행 일부가 노출되지 않게 한다.
// 최초 화면의 본문 6행은 합계 1행 + 데이터 5행이며, 스크롤 후에는 데이터 6행이 온전히 보인다.
const SUMMARY_GRID_ROW_HEIGHT_REM = 3.2;
const SUMMARY_GRID_VISIBLE_BODY_ROW_COUNT = 6;
const SUMMARY_GRID_MAX_HEIGHT = `${
SUMMARY_GRID_ROW_HEIGHT_REM * (SUMMARY_GRID_VISIBLE_BODY_ROW_COUNT + 1)
}rem`;
const PIPE_SUMMARY_TABLE_HEAD = [
{ id: "rowNumber", title: "NO", widthPercent: 6 },
{ id: "trtdstNm", title: "처리분구", widthPercent: 18 },
@@ -229,7 +237,7 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail
<div className="relative mx-[1.2rem] mb-[1.2rem] min-h-0 overflow-hidden">
<div
className="overflow-x-hidden overflow-y-auto"
style={{ maxHeight: "23.8rem", scrollbarGutter: "stable" }}
style={{ maxHeight: SUMMARY_GRID_MAX_HEIGHT, scrollbarGutter: "stable" }}
>
<div
className="card !m-0"
@@ -245,7 +253,7 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail
className="font-bold"
style={{ position: "static", zIndex: "auto" }}
>
<TableRow>
<TableRow style={{ height: `${SUMMARY_GRID_ROW_HEIGHT_REM}rem` }}>
{summaryColumns.map((column) => (
<TableHead
key={column.id}
@@ -265,7 +273,10 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail
<>
<TableRow
className="text-gray-700"
style={{ backgroundColor: "#d2deef" }}
style={{
height: `${SUMMARY_GRID_ROW_HEIGHT_REM}rem`,
backgroundColor: "#d2deef",
}}
>
<TableCell
colSpan={2}
@@ -285,7 +296,10 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail
))}
</TableRow>
{summaryTable.rows.map((row, rowIndex) => (
<TableRow key={`${row.trtdstNm}-${rowIndex}`}>
<TableRow
key={`${row.trtdstNm}-${rowIndex}`}
style={{ height: `${SUMMARY_GRID_ROW_HEIGHT_REM}rem` }}
>
{summaryColumns.map((column) => (
<TableCell
key={`${rowIndex}-${column.id}`}