조사사업 보고서 다운로드 및 상세현황 그리드 보정
This commit is contained in:
@@ -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}`}
|
||||
|
||||
Reference in New Issue
Block a user