그리드 정렬기능

This commit is contained in:
2026-08-26 16:25:29 +09:00
parent ccfbe6ae32
commit e0cdba00be
61 changed files with 1777 additions and 590 deletions
@@ -11,6 +11,7 @@ import { PromptProvider, StoreProvider } from "@/shared/providers";
import { AlertDialog } from "@/shared/ui/alert-dialog";
import { PromptDialog } from "@/shared/ui/prompt-dialog";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { BusinessUserMessageBridge } from "@/shared/ui/business-user-message-bridge";
// 개발 도구는 실제 로컬 개발 환경에서만 별도 청크로 불러와 최초 페이지 컴파일 범위를 줄인다.
const LazyReactQueryDevtools = dynamic(
@@ -26,6 +27,7 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return (
<QueryClientProvider client={queryClient}>
<StoreProvider>
<BusinessUserMessageBridge />
<PromptProvider>
<ConfirmProvider>
<AuthWrapper>
@@ -46,6 +46,7 @@ import type {
import LoadingOverlay from "@/components/ui/LoadingBar";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
type SelectOption = { text: string; val: string };
type CfmtnBgtTableRow = CfmtnBgtRow;
@@ -159,6 +160,8 @@ export default function CfmtnBgtPopupContent({
});
const [search, setSearch] = useState(initial);
// 화면 목록을 마지막으로 조회한 조건이며 페이지 이동·Excel이 동일하게 사용한다.
const [appliedSearch, setAppliedSearch] = useState(initial);
const [codes, setCodes] = useState<CfmtnBgtCode[]>([]);
const [page, setPage] = useState(0);
const [data, setData] = useState(empty);
@@ -414,6 +417,7 @@ export default function CfmtnBgtPopupContent({
: empty,
);
setPage(targetPage);
setAppliedSearch({ ...targetSearch });
setNotice("");
} catch (error) {
console.error(
@@ -449,26 +453,21 @@ export default function CfmtnBgtPopupContent({
};
const download = async () => {
if (JSON.stringify(search) !== JSON.stringify(appliedSearch)) {
setNotice("조회조건이 변경되었습니다. 조회 후 Excel을 다운로드해주세요.");
return;
}
setLoading(true);
try {
let exportPage = 0;
let totalPages = 1;
const exportRows: CfmtnBgtRow[] = [];
while (exportPage < totalPages) {
const result = await getCfmtnBgtPage(params(exportPage, EXPORT_SIZE));
const safeResult =
result &&
typeof result === "object" &&
"content" in result &&
Array.isArray((result as CfmtnBgtPage).content) &&
"totalPages" in result
? (result as CfmtnBgtPage)
: empty;
exportRows.push(...safeResult.content);
totalPages = safeResult.totalPages;
exportPage += 1;
}
downloadCfmtnBgtExcel(exportRows, search.year);
const result = await fetchAllPages<CfmtnBgtPage, CfmtnBgtRow>({
batchSize: EXPORT_SIZE,
fetchPage: (targetPage, size) =>
getCfmtnBgtPage(params(targetPage, size, appliedSearch)),
getItems: (page) => (Array.isArray(page?.content) ? page.content : []),
getTotal: (page) => Number(page?.totalElements ?? 0),
});
downloadCfmtnBgtExcel(result.items, appliedSearch.year);
setNotice("");
} catch (error) {
console.error(
@@ -522,7 +521,7 @@ export default function CfmtnBgtPopupContent({
const next =
typeof updater === "function" ? updater(pagination) : updater;
if (next.pageIndex === page) return;
void load(next.pageIndex);
void load(next.pageIndex, appliedSearch);
},
});
@@ -11,6 +11,7 @@ import {
postRawJson,
putData,
} from "@/components/lib/api";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
const API_PATH = "v1/ctyFldngPrvtnBiz";
@@ -27,21 +28,21 @@ export const getCtyFldngPrvtnBusinesses = (params: URLSearchParams) =>
/** 모든 페이지를 순회하여 Excel 대상 전체를 모은다. */
export const getAllCtyFldngPrvtnBusinesses = async (
search: URLSearchParams,
sort = "ctyFldngPrvtnBizSn,desc",
) => {
const rows: CtyFldngPrvtnBiz[] = [];
let page = 0;
let totalPages = 1;
while (page < totalPages) {
const params = new URLSearchParams(search);
params.set("page", String(page));
params.set("size", "500");
params.set("sort", "ctyFldngPrvtnBizSn,asc");
const result = await getCtyFldngPrvtnBusinesses(params);
rows.push(...result.content);
totalPages = result.totalPages;
page += 1;
}
return rows;
const result = await fetchAllPages<CtyFldngPrvtnBizPage, CtyFldngPrvtnBiz>({
batchSize: 500,
fetchPage: (page, size) => {
const params = new URLSearchParams(search);
params.set("page", String(page));
params.set("size", String(size));
params.set("sort", sort);
return getCtyFldngPrvtnBusinesses(params);
},
getItems: (page) => page.content ?? [],
getTotal: (page) => Number(page.totalElements ?? 0),
});
return result.items;
};
/** 수정 화면의 사업 한 건을 조회한다. */
@@ -41,6 +41,17 @@ const textValue = (value: unknown): string | null => {
return !valueText || valueText === "-" ? null : valueText;
};
/** API 문자열에 남은 Excel 개행 이스케이프를 실제 줄바꿈으로 정리한다. */
const excelTextValue = (value: unknown): string | number => {
if (value === null || value === undefined) return "";
if (typeof value === "number") return value;
if (typeof value !== "string") return String(value);
return value
.replace(/_x000d_/gi, "")
.replace(/\r\n?/g, "\n")
.trim();
};
/** 콤마와 단위를 제거한 유한 숫자를 반환한다. */
const numberValue = (
value: unknown,
@@ -333,7 +344,7 @@ const writeDetail = (
detail.exlnPplImprvmLen,
detail.rnwtrPumpstSumCn,
].forEach((v) => {
row[start + o++] = v ?? "";
row[start + o++] = excelTextValue(v);
});
for (let i = 0; i < PUMP_COUNTS[code]; i += 1)
row[start + o++] =
@@ -346,7 +357,7 @@ const writeDetail = (
row[start + o++] = detail.impdFclt?.find((v) => v.no === i + 1)?.cpct ?? "";
[detail.tnlDmtr, detail.tnlLen, detail.tnlRscpt, detail.tnlExcvDpth].forEach(
(v) => {
row[start + o++] = v ?? "";
row[start + o++] = excelTextValue(v);
},
);
if (code !== "EM") {
@@ -363,10 +374,10 @@ const writeDetail = (
detail.frstAprvCn,
detail.frstChgAprvCn,
].forEach((v) => {
row[start + o++] = v ?? "";
row[start + o++] = excelTextValue(v);
});
if (code === "MP") row[start + o++] = detail.scndChgAprvCn ?? "";
if (code === "CW") row[start + o++] = detail.rmrkSrcCn ?? "";
if (code === "MP") row[start + o++] = excelTextValue(detail.scndChgAprvCn);
if (code === "CW") row[start + o++] = excelTextValue(detail.rmrkSrcCn);
}
};
@@ -557,6 +568,7 @@ const saveStyledWorkbook = async (
filename: string,
starts: Record<(typeof DETAIL_CODES)[number], number>,
last: number,
rowCount: number,
) => {
const data = XLSX.write(workbook, { type: "array", bookType: "xlsx" });
const styledWorkbook = await XlsxPopulate.fromDataAsync(data);
@@ -571,6 +583,23 @@ const saveStyledWorkbook = async (
wrapText: true,
border: true,
};
styledSheet.range("B2:Q3").style({
bold: true,
fontFamily: "맑은 고딕",
fontSize: 16,
horizontalAlignment: "center",
verticalAlignment: "center",
border: true,
});
styledSheet.range("B5:N5").style({
bold: true,
fontFamily: "맑은 고딕",
fontSize: 10,
horizontalAlignment: "center",
verticalAlignment: "center",
fill: "D9EAF7",
border: true,
});
styledSheet.range(`B8:${lastColumn}12`).style(commonHeaderStyle);
styledSheet
.range(`B8:${XLSX.utils.encode_col(starts.EM - 1)}12`)
@@ -591,6 +620,32 @@ const saveStyledWorkbook = async (
[8, 9, 10, 11, 12].forEach((row) =>
styledSheet.row(row).height(row === 8 ? 30 : 24),
);
styledSheet.row(2).height(26);
styledSheet.row(3).height(26);
styledSheet.row(5).height(22);
if (rowCount > 0) {
const dataStartRow = DATA_START_ROW + 1;
const dataEndRow = DATA_START_ROW + rowCount;
styledSheet.range(`B${dataStartRow}:${lastColumn}${dataEndRow}`).style({
fontFamily: "맑은 고딕",
fontSize: 10,
horizontalAlignment: "center",
verticalAlignment: "center",
wrapText: false,
border: true,
});
styledSheet
.range(`R${dataStartRow}:${lastColumn}${dataEndRow}`)
.style("numberFormat", "#,##0.###");
styledSheet
.range(`M${dataStartRow}:Q${dataEndRow}`)
.style("horizontalAlignment", "left");
styledSheet
.range(`${lastColumn}${dataStartRow}:${lastColumn}${dataEndRow}`)
.style({ horizontalAlignment: "left", wrapText: true });
for (let row = dataStartRow; row <= dataEndRow; row += 1)
styledSheet.row(row).height(22);
}
const blob = await styledWorkbook.outputAsync();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
@@ -625,7 +680,30 @@ export const downloadCtyFldngPrvtnExcel = async (
Array(last + 1).fill(""),
);
const merges: XLSX.Range[] = [];
aoa[0]![1] = "하수도정비중점관리지역 도시침수대응사업 현황";
aoa[1]![1] = "하수도정비중점관리지역 도시침수 대응사업 추진 현황";
addMerge(merges, 1, 1, 2, 16);
if (templateOnly) {
putHeader(aoa, 4, 1, "업로드 양식 · 13행부터 사업자료를 입력해 주세요.");
addMerge(merges, 4, 1, 4, 13);
} else {
const totalBudget = businesses.reduce(
(sum, business) => sum + (business.tpco ?? 0),
0,
);
putHeader(aoa, 4, 1, "조회 건수");
addMerge(merges, 4, 1, 4, 3);
putHeader(aoa, 4, 4, `${businesses.length.toLocaleString("ko-KR")}건`);
addMerge(merges, 4, 4, 4, 6);
putHeader(aoa, 4, 7, "총사업비 합계(백만원)");
addMerge(merges, 4, 7, 4, 9);
putHeader(
aoa,
4,
10,
totalBudget ? totalBudget.toLocaleString("ko-KR") : "-",
);
addMerge(merges, 4, 10, 4, 13);
}
const verticalBasicHeaders: Array<[number, string]> = [
[1, "순번"],
[2, "단계"],
@@ -680,7 +758,7 @@ export const downloadCtyFldngPrvtnExcel = async (
businesses.forEach((b, index) => {
const row = Array(last + 1).fill("");
[
b.seqNo,
index + 1,
b.stp,
b.ctpvNm,
b.sggNm,
@@ -700,11 +778,22 @@ export const downloadCtyFldngPrvtnExcel = async (
b.gvexp,
b.lcexp,
].forEach((v, i) => {
row[i + 1] = v ?? "";
row[i + 1] = excelTextValue(v);
});
years.forEach((year, i) => {
row[21 + i] =
b.settlements?.find((s) => s.soaYr === year)?.soaAmt ?? "";
const settlementAmounts = years.map(
(year) => b.settlements?.find((s) => s.soaYr === year)?.soaAmt,
);
const hasSettlement = settlementAmounts.some(
(amount) => amount !== null && amount !== undefined,
);
row[20] = hasSettlement
? settlementAmounts.reduce<number>(
(sum, amount) => sum + (amount ?? 0),
0,
)
: "";
settlementAmounts.forEach((amount, i) => {
row[21 + i] = amount ?? "";
});
DETAIL_CODES.forEach((code) =>
writeDetail(
@@ -714,14 +803,24 @@ export const downloadCtyFldngPrvtnExcel = async (
code,
),
);
row[last - 2] = b.picNm ?? "";
row[last - 1] = b.picCnpl ?? "";
row[last] = b.excptnMttrCn ?? "";
row[last - 2] = excelTextValue(b.picNm);
row[last - 1] = excelTextValue(b.picCnpl);
row[last] = excelTextValue(b.excptnMttrCn);
aoa.push(row);
});
const sheet = XLSX.utils.aoa_to_sheet(aoa);
sheet["!merges"] = merges;
sheet["!cols"] = Array.from({ length: last + 1 }, () => ({ wch: 14 }));
const columnWidths = Array.from({ length: last + 1 }, () => ({ wch: 11 }));
const widthOverrides: Array<[number, number]> = [
[0, 3], [1, 7], [2, 9], [3, 12], [4, 12], [5, 12], [6, 11],
[7, 12], [8, 10], [9, 9], [10, 13], [11, 13], [12, 32], [13, 30],
[14, 16], [15, 20], [16, 15], [17, 14], [18, 14], [19, 14],
[last - 2, 12], [last - 1, 16], [last, 42],
];
widthOverrides.forEach(([column, width]) => {
columnWidths[column] = { wch: width };
});
sheet["!cols"] = columnWidths;
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, SHEET_NAME);
await saveStyledWorkbook(
@@ -729,5 +828,6 @@ export const downloadCtyFldngPrvtnExcel = async (
templateOnly ? "도시침수예방사업_업로드양식.xlsx" : DOWNLOAD_FILENAME,
starts,
last,
templateOnly ? 0 : businesses.length,
);
};
@@ -5,7 +5,9 @@ import { useRouter, useSearchParams } from "next/navigation";
import {
StandardDataTable,
StandardTableSortHeader,
getStandardRowNumber,
type UiTableSort,
} from "@common/business_components";
import {
Button,
@@ -206,10 +208,24 @@ const createTableColumn = (
id: string,
title: string,
widthPercent = 8,
sortKey?: string,
sort?: UiTableSort,
onSortChange?: (sort: UiTableSort | undefined) => void,
): ColumnDef<TableRow> => ({
id,
accessorFn: (row) => row[id],
header: () => <span className="whitespace-pre-line">{title}</span>,
header: () =>
sortKey && onSortChange ? (
<StandardTableSortHeader
label={title}
title={<span className="whitespace-pre-line">{title}</span>}
sortKey={sortKey}
sort={sort}
onSortChange={onSortChange}
/>
) : (
<span className="whitespace-pre-line">{title}</span>
),
cell: ({ getValue }) => {
const value = getValue();
const displayValue =
@@ -292,6 +308,7 @@ export function CtyFldngPrvtnBizListPage() {
const [sggCd, setSggCd] = useState(params.get("sggCd") ?? "0");
const [appliedCtpvCd, setAppliedCtpvCd] = useState(ctpvCd);
const [appliedSggCd, setAppliedSggCd] = useState(sggCd);
const [sort, setSort] = useState<UiTableSort>();
const availableSgg = useMemo(
() =>
sgg.filter(
@@ -308,10 +325,29 @@ export function CtyFldngPrvtnBizListPage() {
),
[],
);
/** 정렬 변경 시 서버 첫 페이지로 돌아가고 동일 검색조건을 유지한다. */
const handleSortChange = useCallback(
(nextSort?: UiTableSort) => {
setSort(nextSort);
setPage(1);
const q = listQuery(1, applied, appliedCtpvCd, appliedSggCd);
router.replace(`/oper/ctyFldngPrvtnBiz${q ? `?${q}` : ""}`, {
scroll: false,
});
},
[applied, appliedCtpvCd, appliedSggCd, router],
);
const columns = useMemo<ColumnDef<TableRow>[]>(
() => [
...BASIC_HEADLIST.map((head) =>
createTableColumn(head.id, head.title, head.widthPercent),
createTableColumn(
head.id,
head.title,
head.widthPercent,
head.id === "rowNumber" ? "ctyFldngPrvtnBizSn" : head.id,
sort,
handleSortChange,
),
),
{
id: "settlements",
@@ -332,10 +368,17 @@ export function CtyFldngPrvtnBizListPage() {
),
})),
...TAIL_HEADLIST.map((head) =>
createTableColumn(head.id, head.title, head.widthPercent),
createTableColumn(
head.id,
head.title,
head.widthPercent,
head.id,
sort,
handleSortChange,
),
),
],
[years],
[handleSortChange, sort, years],
);
const pagination: PaginationState = {
pageIndex: page - 1,
@@ -368,11 +411,17 @@ export function CtyFldngPrvtnBizListPage() {
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
/** 확정된 조건으로 서버 목록을 갱신한다. */
const load = useCallback(
async (target = page, condition = applied) => {
async (
target = page,
condition = applied,
nextSort: UiTableSort | undefined = sort,
) => {
const query = new URLSearchParams({
page: String(target - 1),
size: String(PAGE_SIZE),
sort: "ctyFldngPrvtnBizSn,desc",
sort: nextSort
? `${nextSort.key},${nextSort.direction}`
: "ctyFldngPrvtnBizSn,desc",
});
Object.entries(condition).forEach(([k, v]) => {
if (v.trim()) query.set(k, v.trim());
@@ -404,7 +453,7 @@ export function CtyFldngPrvtnBizListPage() {
setLoading(false);
}
},
[applied, page],
[applied, page, sort],
);
useEffect(() => {
void load();
@@ -453,7 +502,10 @@ export function CtyFldngPrvtnBizListPage() {
if (v.trim()) query.set(k, v.trim());
});
await downloadCtyFldngPrvtnExcel(
await getAllCtyFldngPrvtnBusinesses(query),
await getAllCtyFldngPrvtnBusinesses(
query,
sort ? `${sort.key},${sort.direction}` : undefined,
),
);
} catch (e) {
setNotice(e instanceof Error ? e.message : "엑셀을 내려받지 못했습니다.");
@@ -478,6 +530,7 @@ export function CtyFldngPrvtnBizListPage() {
setSggCd("0");
setAppliedCtpvCd("0");
setAppliedSggCd("0");
setSort(undefined);
setNotice("");
setPage(1);
router.replace("/oper/ctyFldngPrvtnBiz", { scroll: false });
@@ -372,17 +372,19 @@ export const getPplSummaries = async (
exmnBizId: string,
params: { trtdstId?: string; page: number; size: number; sort?: string },
): Promise<ExmnPage<PplSummaryRow>> => {
const query = new URLSearchParams({
page: String(params.page),
size: String(params.size),
});
if (params.trtdstId) query.set("trtdstId", params.trtdstId);
if (params.sort) query.set("sort", params.sort);
return toPage<PplSummaryRow>(
await getData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/summaries/ppl?${query}`,
),
);
const summaries = await getExmnSectionSummaries(exmnBizId, "PPL");
const filtered = params.trtdstId
? summaries.filter((row) => row.trtdstId === params.trtdstId)
: summaries;
const start = Math.max(params.page, 0) * Math.max(params.size, 1);
const size = Math.max(params.size, 1);
return {
content: filtered.slice(start, start + size),
totalElements: filtered.length,
number: Math.max(params.page, 0),
size,
totalPages: Math.ceil(filtered.length / size),
};
};
/** 선택 사업의 PPL 시설을 처리분구·구간·GIS번호·등급으로 조회한다. */
@@ -408,17 +410,3 @@ export const getPplFacilities = async (
if (params.sort) query.set("sort", params.sort);
return toPage<PplFacility>(await getData(`v1/exmnFacilities?${query}`));
};
/** 현재 PPL 조회조건을 서버 엑셀로 내려받는다. */
export const downloadPplExport = async (
exmnBizId: string,
trtdstId?: string,
): Promise<Blob> => {
const query = new URLSearchParams();
if (trtdstId) query.set("trtdstId", trtdstId);
const queryString = query.toString();
const suffix = queryString ? `?${queryString}` : "";
return getBlobData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/exports/ppl${suffix}`,
);
};
@@ -19,11 +19,12 @@ import {
import {
STANDARD_ROW_NUMBER_HEADER,
StandardDataTable,
StandardTableSortHeader,
getStandardRowNumber,
type UiTableSort,
} from "@common/business_components";
import {
getCoreRowModel,
getPaginationRowModel,
useReactTable,
type ColumnDef,
type PaginationState,
@@ -40,20 +41,23 @@ import type {
import { ExmnStatusDetailPanel } from "./ExmnStatusDetailPanel";
import { EXMN_TABLE_HEADER_CLASS } from "./ExmnStandardDataTable";
// 서버 호환을 위해 전체 후보를 한 번에 받고 공통 표에서 10건씩 페이지 표시한다.
const BUSINESS_LIST_SCROLL_FETCH_SIZE = 1000;
const BUSINESS_LIST_PAGE_SIZE = 10;
const BUSINESS_TABLE_HEAD = [
{ id: "rowNumber", title: STANDARD_ROW_NUMBER_HEADER },
{ id: "bizCyclText", title: "사업차수" },
{ id: "bizYr", title: "사업년도" },
{ id: "ctpvNm", title: "시도" },
{ id: "sggNm", title: "시군구" },
{ id: "bizNm", title: "사업명" },
{ id: "trtdstCntText", title: "처리구역·분구(개소)" },
{ id: "prcisExmnPrlgText", title: "정밀조사 연장(km)" },
{ id: "reportYnText", title: "보고서" },
{ 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 {
@@ -94,6 +98,8 @@ export function ExmnRegistrationPage() {
pageIndex: 0,
pageSize: BUSINESS_LIST_PAGE_SIZE,
});
// 표시명 컬럼도 같은 이름 필드를 사용하는 서버 단일 정렬 상태다.
const [sort, setSort] = useState<UiTableSort>();
// 선택 사업 ID는 서버 재조회 뒤에도 같은 행을 복원할 수 있도록 객체와 별도로 유지한다.
const [selectedBusinessId, setSelectedBusinessId] = useState<string | null>(
null,
@@ -143,6 +149,8 @@ export function ExmnRegistrationPage() {
rowNumber: getStandardRowNumber({
total: totalElements,
rowIndex: index,
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
}),
bizCyclText: business.bizCycl == null ? "-" : `${business.bizCycl}차`,
trtdstCntText: Number(business.trtdstCnt ?? 0).toLocaleString("ko-KR"),
@@ -156,35 +164,47 @@ export function ExmnRegistrationPage() {
? "있음"
: "없음"),
})),
[businesses, totalElements],
[businesses, pagination.pageIndex, pagination.pageSize, totalElements],
);
/** 화면 조건을 행정구역 이름과 목록 내부 스크롤용 단일 서버 Page 계약으로 변환한다. */
/** 화면 조건과 현재 정렬을 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: 0,
size: BUSINESS_LIST_SCROLL_FETCH_SIZE,
page: pageIndex,
size: BUSINESS_LIST_PAGE_SIZE,
sort: nextSort ? `${nextSort.key},${nextSort.direction}` : undefined,
});
/** 지정 조회조건으로 사업 목록을 읽고 선택 행을 가능한 경우 유지한다. */
const fetchBusinesses = async (filters: BusinessFilters) => {
/** 지정 조회조건·정렬·페이지로 사업 목록을 읽고 현재 페이지 선택을 복원한다. */
const fetchBusinesses = async (
filters: BusinessFilters,
pageIndex = 0,
nextSort: UiTableSort | undefined = sort,
) => {
setIsLoading(true);
setNotice(null);
try {
const result = await getExmnBusinesses(buildSearchParams(filters));
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: 0 }));
setPagination((current) => ({
...current,
pageIndex: result.number,
}));
return true;
} catch (error) {
setBusinesses([]);
@@ -207,7 +227,7 @@ export function ExmnRegistrationPage() {
useEffect(() => {
if (initialBusinessesLoadedRef.current) return;
initialBusinessesLoadedRef.current = true;
void fetchBusinesses(EMPTY_FILTERS);
void fetchBusinesses(EMPTY_FILTERS, 0, undefined);
// 최초 진입 시점의 빈 조회조건으로 한 번만 실행한다.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -215,7 +235,7 @@ export function ExmnRegistrationPage() {
/** 입력 중 조건을 확정하고 첫 페이지를 조회한다. */
const handleSearch = async () => {
setAppliedFilters(draftFilters);
await fetchBusinesses(draftFilters);
await fetchBusinesses(draftFilters, 0, sort);
};
/** CS 초기화 버튼으로 모든 검색조건과 페이지를 최초 조회 기준으로 복원한다. */
@@ -223,7 +243,8 @@ export function ExmnRegistrationPage() {
setDraftFilters(EMPTY_FILTERS);
setAppliedFilters(EMPTY_FILTERS);
setSelectedBusinessId(null);
await fetchBusinesses(EMPTY_FILTERS);
setSort(undefined);
await fetchBusinesses(EMPTY_FILTERS, 0, undefined);
};
/** 목록의 보고서 링크에서 첨부서비스 원본 PDF를 내려받는다. */
@@ -332,7 +353,7 @@ export function ExmnRegistrationPage() {
event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_CREATED
) {
setSelectedBusinessId(null);
void fetchBusinesses(appliedFilters).then((refreshed) => {
void fetchBusinesses(appliedFilters, 0, sort).then((refreshed) => {
if (refreshed)
setNotice({ type: "success", text: "조사사업이 등록되었습니다." });
});
@@ -342,7 +363,11 @@ export function ExmnRegistrationPage() {
if (
event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_UPDATED
) {
void fetchBusinesses(appliedFilters).then((refreshed) => {
void fetchBusinesses(
appliedFilters,
pagination.pageIndex,
sort,
).then((refreshed) => {
if (refreshed)
setNotice({ type: "success", text: "조사사업이 수정되었습니다." });
});
@@ -352,7 +377,11 @@ export function ExmnRegistrationPage() {
if (
event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED
) {
void fetchBusinesses(appliedFilters).then((refreshed) => {
void fetchBusinesses(
appliedFilters,
pagination.pageIndex,
sort,
).then((refreshed) => {
if (refreshed)
setNotice({
type: "success",
@@ -364,7 +393,14 @@ export function ExmnRegistrationPage() {
window.addEventListener("message", handlePopupMessage);
return () => window.removeEventListener("message", handlePopupMessage);
}, [appliedFilters]);
}, [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]>[]>(
@@ -372,7 +408,17 @@ export function ExmnRegistrationPage() {
BUSINESS_TABLE_HEAD.map((head) => ({
id: head.id,
accessorFn: (row) => row[head.id as keyof typeof row],
header: head.title,
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";
@@ -420,7 +466,7 @@ export function ExmnRegistrationPage() {
align: "center" as const,
},
})),
[],
[appliedFilters, sort],
);
const table = useReactTable({
@@ -431,9 +477,16 @@ export function ExmnRegistrationPage() {
rowSelection: selectedBusinessId ? { [selectedBusinessId]: true } : {},
},
getRowId: (row) => row.exmnBizId,
onPaginationChange: setPagination,
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(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
@@ -2,6 +2,11 @@
import LoadingOverlay from "@/components/ui/LoadingBar";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import {
StandardTableSortHeader,
sortStandardTableRows,
type UiTableSort,
} from "@common/business_components";
import {
Button,
Table,
@@ -28,23 +33,23 @@ import { EXMN_TABLE_HEADER_HEIGHT_REM } from "./ExmnStandardDataTable";
const SUMMARY_GRID_ROW_HEIGHT_REM = 3.2;
const PIPE_SUMMARY_TABLE_HEAD = [
{ id: "rowNumber", title: "순번", widthPercent: 6 },
{ id: "trtdstNm", title: "처리분구", widthPercent: 18 },
{ id: "trgtPrlgText", title: "대상연장(m)", widthPercent: 13 },
{ id: "drvngDstncText", title: "주행거리(m)", widthPercent: 13 },
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 15 },
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 14 },
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 14 },
{ id: "etcCountText", title: "기타", widthPercent: 7 },
{ id: "rowNumber", title: "순번", widthPercent: 6, sortKey: "rowNumber" },
{ id: "trtdstNm", title: "처리분구", widthPercent: 18, sortKey: "trtdstNm" },
{ id: "trgtPrlgText", title: "대상연장(m)", widthPercent: 13, sortKey: "trgtPrlgText" },
{ id: "drvngDstncText", title: "주행거리(m)", widthPercent: 13, sortKey: "drvngDstncText" },
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 15, sortKey: "wholeCountText" },
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 14, sortKey: "structuralCountText" },
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 14, sortKey: "operationalCountText" },
{ id: "etcCountText", title: "기타", widthPercent: 7, sortKey: "etcCountText" },
];
const MANHOLE_SUMMARY_TABLE_HEAD = [
{ id: "rowNumber", title: "순번", widthPercent: 8 },
{ id: "trtdstNm", title: "처리분구", widthPercent: 28 },
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 18 },
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 17 },
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 17 },
{ id: "etcCountText", title: "기타", widthPercent: 12 },
{ id: "rowNumber", title: "순번", widthPercent: 8, sortKey: "rowNumber" },
{ id: "trtdstNm", title: "처리분구", widthPercent: 28, sortKey: "trtdstNm" },
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 18, sortKey: "wholeCountText" },
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 17, sortKey: "structuralCountText" },
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 17, sortKey: "operationalCountText" },
{ id: "etcCountText", title: "기타", widthPercent: 12, sortKey: "etcCountText" },
];
type Notice = { type: "info" | "error"; text: string } | null;
@@ -88,6 +93,8 @@ export function ExmnStatusDetailPanel({
const [summaryRows, setSummaryRows] = useState<ExmnSectionSummaryRow[]>([]);
/** 상세보기 팝업에 전달할 현재 선택 처리분구다. */
const [selectedTrtdstId, setSelectedTrtdstId] = useState<string | null>(null);
// 서버가 반환한 전체 처리분구 집계행에 적용하는 단일 로컬 정렬 상태다.
const [sort, setSort] = useState<UiTableSort>();
const [isLoading, setIsLoading] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
@@ -96,11 +103,13 @@ export function ExmnStatusDetailPanel({
useEffect(() => {
setJobType("PPL");
setSelectedTrtdstId(null);
setSort(undefined);
setNotice(null);
}, [business?.exmnBizId]);
useEffect(() => {
setSelectedTrtdstId(null);
setSort(undefined);
}, [jobType]);
useEffect(() => {
@@ -176,7 +185,7 @@ export function ExmnStatusDetailPanel({
etc: 0,
},
);
const rows: DisplaySummaryRow[] = summaryRows.map((row, index) => {
const displayRows: DisplaySummaryRow[] = summaryRows.map((row, index) => {
const counts = getCounts(row);
return {
trtdstId: row.trtdstId,
@@ -191,7 +200,7 @@ export function ExmnStatusDetailPanel({
};
});
return {
rows,
rows: sortStandardTableRows(displayRows, sort),
totals: {
trgtPrlgText: formatNumber(totals.trgtPrlg),
drvngDstncText: formatNumber(totals.drvngDstnc),
@@ -201,7 +210,7 @@ export function ExmnStatusDetailPanel({
etcCountText: formatNumber(totals.etc),
} as Record<string, string>,
};
}, [jobType, summaryRows]);
}, [jobType, sort, summaryRows]);
const summaryColumns =
jobType === "PPL" ? PIPE_SUMMARY_TABLE_HEAD : MANHOLE_SUMMARY_TABLE_HEAD;
@@ -299,6 +308,12 @@ export function ExmnStatusDetailPanel({
setNotice(null);
};
/** 정렬 변경 시 상세 대상 선택을 해제하고 전체 집계행을 다시 배열한다. */
const handleSummarySortChange = (nextSort?: UiTableSort) => {
setSort(nextSort);
setSelectedTrtdstId(null);
};
/** 보이는 버튼 전체에서 조사유형을 즉시 바꾸고, 상세 조회는 별도 effect에서 수행한다. */
const handleJobTypeChange = (nextJobType: ExmnJobType) => {
if (nextJobType === jobType) return;
@@ -417,7 +432,12 @@ export function ExmnStatusDetailPanel({
className="sticky top-0 z-20 !py-[1.2rem] text-center"
style={{ height: `${EXMN_TABLE_HEADER_HEIGHT_REM}rem` }}
>
{column.title}
<StandardTableSortHeader
label={column.title}
sortKey={column.sortKey}
sort={sort}
onSortChange={handleSummarySortChange}
/>
</TableHead>
))}
</TableRow>
@@ -3,7 +3,12 @@
import type { UIEvent } from "react";
import { useEffect, useMemo, useState } from "react";
import { StandardPopupLayout } from "@common/business_components";
import {
StandardPopupLayout,
StandardTableSortHeader,
sortStandardTableRows,
type UiTableSort,
} from "@common/business_components";
import {
Button,
Input,
@@ -16,6 +21,9 @@ import {
Table,
} from "@common/components";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import PopupSearchPanel, {
PopupSearchActions,
} from "@/features/windowPopup/ui/PopupSearchPanel";
import {
getExmnFacilityDetails,
@@ -164,6 +172,8 @@ export function ExmnSurveyDetailPopupPage({
const [searchRequestSeq, setSearchRequestSeq] = useState(0);
/** 가로 스크롤 위치를 기준으로 요약 라벨과 고정 열 경계선 위치를 함께 보정한다. */
const [tableScrollLeft, setTableScrollLeft] = useState(0);
// 관로·맨홀 고정 정보 열을 전체 조회 결과에서 정렬하는 상태다.
const [sort, setSort] = useState<UiTableSort>();
/** 다운로드 미연결 등 현재 팝업 안내 메시지를 표시한다. */
const [notice, setNotice] = useState<Notice>(null);
/** 실제 DB 상세조회 결과와 요청 상태다. */
@@ -246,13 +256,20 @@ export function ExmnSurveyDetailPopupPage({
row.pipeNo.toLocaleLowerCase("ko-KR").includes(normalizedKeyword),
);
}, [actualUnitRows, appliedDetailNumberKeyword, isUnitMode]);
const rows = filteredRows;
const rows = useMemo(
() =>
sortStandardTableRows(filteredRows, sort, (row, key) =>
getFixedCellValue(row, key as ExmnDetailFixedColumnId),
),
[filteredRows, sort],
);
/** 관로·맨홀 조사유형 또는 결함 범위가 바뀌면 이전 검색 조건과 안내를 초기화한다. */
useEffect(() => {
setDetailMode("PIPE");
setDetailNumberKeyword("");
setAppliedDetailNumberKeyword("");
setSort(undefined);
setNotice(null);
}, [defectType, jobType]);
@@ -750,7 +767,7 @@ export function ExmnSurveyDetailPopupPage({
<section className="flex min-w-0 flex-col gap-[1.2rem]">
{!isDefectTypeView && (
<div className="card card-border !m-0 flex flex-wrap items-center gap-3 !p-[1.2rem]">
<PopupSearchPanel contentClassName="gap-3">
<div className="flex items-center gap-2">
<Label
htmlFor="exmn-detail-district"
@@ -835,7 +852,7 @@ export function ExmnSurveyDetailPopupPage({
</div>
)}
<div className="ml-auto flex items-center gap-2">
<PopupSearchActions>
<Button type="button" color="primary" onClick={handleSearch}>
조회
</Button>
@@ -852,8 +869,8 @@ export function ExmnSurveyDetailPopupPage({
>
초기화
</Button>
</div>
</div>
</PopupSearchActions>
</PopupSearchPanel>
)}
{loadError && (
@@ -978,9 +995,23 @@ export function ExmnSurveyDetailPopupPage({
: undefined
}
>
<span className="whitespace-pre-line">
{columnLabel}
</span>
{columnIndex < 9 ? (
<StandardTableSortHeader
label={columnLabel.replace(/\n/g, " ")}
title={
<span className="whitespace-pre-line">
{columnLabel}
</span>
}
sortKey={column.id}
sort={sort}
onSortChange={setSort}
/>
) : (
<span className="whitespace-pre-line">
{columnLabel}
</span>
)}
</th>
);
})}
@@ -12,7 +12,6 @@ import {
} from "@common/components/ui";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
downloadPplExport,
getExmnBusiness,
getPplFacilities,
getPplSummaries,
@@ -25,6 +24,8 @@ import type {
} from "../model/exmn-business-types";
import { PplFacilityDetailModal } from "./PplFacilityDetailModal";
import { ExmnStandardDataTable } from "./ExmnStandardDataTable";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
const SUMMARY_TABLE_HEAD = [
{ id: "trtdstNm", title: "처리분구", widthPercent: 20 },
@@ -121,16 +122,6 @@ interface PplSummaryModalProps {
const formatNumber = (value: number | null | undefined) =>
Number(value ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 3 });
/** Blob을 PPL 집계 엑셀 다운로드로 전달한다. */
const saveBlob = (blob: Blob, fileName: string) => {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(objectUrl);
};
/** 처리분구별 PPL 집계에서 결함 수를 따라 시설 목록과 단위 상세까지 탐색하는 모달이다. */
export function PplSummaryModal({
open,
@@ -352,10 +343,40 @@ export function PplSummaryModal({
setIsLoading(true);
setNotice(null);
try {
saveBlob(
await downloadPplExport(business.exmnBizId, trtdstId || undefined),
`${business.bizNm}_PPL집계.xlsx`,
);
const result = await fetchAllPages({
fetchPage: (page, size) =>
getPplSummaries(business.exmnBizId, {
trtdstId: trtdstId || undefined,
page,
size,
}),
getItems: (page) => page.content ?? [],
getTotal: (page) => Number(page.totalElements ?? 0),
});
const excelRows = result.items.map((row) => ({
trtdstNm: row.trtdstNm,
trgtPrlg: row.trgtPrlg,
drvngDstnc: row.drvngDstnc,
pplExmnWholAnmlyArtclCnt: row.pplExmnWholAnmlyArtclCnt,
pplExmnStrctDfcCnt: row.pplExmnStrctDfcCnt,
pplExmnOperDfcCnt: row.pplExmnOperDfcCnt,
pplExmnEtcCnt: row.pplExmnEtcCnt,
}));
await downloadExcelWithKoreanHeaders({
data: excelRows as unknown as JSON[],
fileName: `${business.bizNm}_PPL집계.xlsx`,
sheetNameArray: ["처리분구별 PPL 집계"],
headerRange: "A1:G1",
headerMapping: {
trtdstNm: "처리분구",
trgtPrlg: "대상연장(m)",
drvngDstnc: "주행거리(m)",
pplExmnWholAnmlyArtclCnt: "PPL 전체이상",
pplExmnStrctDfcCnt: "구조결함",
pplExmnOperDfcCnt: "운영결함",
pplExmnEtcCnt: "기타",
},
});
} catch (error) {
setNotice({
type: "error",
@@ -5,7 +5,10 @@ import useStore from "@/store/useStore";
import {
STANDARD_ROW_NUMBER_HEADER,
StandardDataTable,
StandardTableSortHeader,
getStandardRowNumber,
sortStandardTableRows,
type UiTableSort,
} from "@common/business_components";
import {
Button,
@@ -143,6 +146,8 @@ export function TrtdstManagementPage() {
pageIndex: 0,
pageSize: 10,
});
// 서버가 반환한 전체 활성 처리분구를 페이지 절단 전에 정렬하는 상태다.
const [sort, setSort] = useState<UiTableSort>();
// DataTable 선택 행은 저장·재조회 중에도 로컬 키 기준으로 명확히 추적한다.
const [selectedRowKey, setSelectedRowKey] = useState<string | null>(null);
// 조회·삭제 참조 확인·저장 요청 중 화면의 중복 조작을 막는다.
@@ -255,6 +260,7 @@ export function TrtdstManagementPage() {
}
setCtpvCd("0");
setSggCd("0");
setSort(undefined);
if (await fetchRows(EMPTY_APPLIED_REGION)) {
setAppliedRegion(EMPTY_APPLIED_REGION);
}
@@ -275,6 +281,8 @@ export function TrtdstManagementPage() {
return;
}
setEditingRowKey(selectedRow.localKey);
setSort(undefined);
setPagination((current) => ({ ...current, pageIndex: 0 }));
};
/** 선택한 행정구역으로 빈 신규 행을 만들고 처리분구명은 그리드에서 직접 입력받는다. */
@@ -289,6 +297,8 @@ export function TrtdstManagementPage() {
}
const localKey = createLocalKey();
setSort(undefined);
setPagination((current) => ({ ...current, pageIndex: 0 }));
setRows((current) =>
withDescendingRowNumbers([
...current,
@@ -464,13 +474,30 @@ export function TrtdstManagementPage() {
}
};
const sortedRows = useMemo(
() => sortStandardTableRows(rows, sort),
[rows, sort],
);
/** 인라인 편집과 변경 상태 표시를 유지한 채 공통 DataTable 열로 변환한다. */
const columns = useMemo<ColumnDef<TrtdstTableRow>[]>(
() =>
TRTDST_TABLE_HEAD.map((head) => ({
id: head.id,
accessorKey: head.id,
header: head.title,
header: () => (
<StandardTableSortHeader
label={head.title}
sortKey={head.id}
sort={sort}
onSortChange={(nextSort) => {
setSort(nextSort);
setPagination((current) => ({ ...current, pageIndex: 0 }));
setSelectedRowKey(null);
setEditingRowKey(null);
}}
/>
),
cell: ({ row, getValue }) => {
const item = row.original;
const isNameEditable =
@@ -505,11 +532,11 @@ export function TrtdstManagementPage() {
},
meta: { width: head.width, align: "center" as const },
})),
[editingRowKey],
[editingRowKey, sort],
);
const table = useReactTable({
data: rows,
data: sortedRows,
columns,
state: {
pagination,
@@ -11,6 +11,7 @@ import {
postRawJson,
putData,
} from "@/components/lib/api";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
const API_PATH = "v1/obsltPplBiz";
const EXCEL_DOWNLOAD_PAGE_SIZE = 500;
@@ -24,30 +25,35 @@ const unwrapData = <T>(body: unknown): T => {
};
/** 목록 검색조건과 페이지 번호로 활성 사업 목록을 조회한다. */
export const getObsltPplBusinesses = async (params: URLSearchParams): Promise<ObsltPplPage> =>
export const getObsltPplBusinesses = async (
params: URLSearchParams,
): Promise<ObsltPplPage> =>
getData(`${API_PATH}?${params.toString()}`) as Promise<ObsltPplPage>;
/** 적용된 조회조건을 유지하며 모든 페이지를 순회해 Excel 다운로드 대상 전체를 모은다. */
export const getAllObsltPplBusinesses = async (searchParams: URLSearchParams): Promise<ObsltPplBusiness[]> => {
const rows: ObsltPplBusiness[] = [];
let page = 0;
let totalPages = 1;
while (page < totalPages) {
const params = new URLSearchParams(searchParams);
params.set("page", String(page));
params.set("size", String(EXCEL_DOWNLOAD_PAGE_SIZE));
params.set("sort", "obsltSwagePplBizSn,asc");
const result = await getObsltPplBusinesses(params);
rows.push(...result.content);
totalPages = result.totalPages;
page += 1;
}
return rows;
export const getAllObsltPplBusinesses = async (
searchParams: URLSearchParams,
sort = "obsltSwagePplBizSn,desc",
): Promise<ObsltPplBusiness[]> => {
const result = await fetchAllPages<ObsltPplPage, ObsltPplBusiness>({
batchSize: EXCEL_DOWNLOAD_PAGE_SIZE,
fetchPage: (page, size) => {
const params = new URLSearchParams(searchParams);
params.set("page", String(page));
params.set("size", String(size));
params.set("sort", sort);
return getObsltPplBusinesses(params);
},
getItems: (page) => page.content ?? [],
getTotal: (page) => Number(page.totalElements ?? 0),
});
return result.items;
};
/** 수정 화면에서 사업 상세 한 건을 조회한다. */
export const getObsltPplBusiness = async (id: string): Promise<ObsltPplBusiness> =>
export const getObsltPplBusiness = async (
id: string,
): Promise<ObsltPplBusiness> =>
getData(`${API_PATH}/${encodeURIComponent(id)}`) as Promise<ObsltPplBusiness>;
/** 등록 화면의 입력값으로 사업 한 건을 생성한다. */
@@ -61,7 +67,10 @@ export const updateObsltPplBusiness = async (
id: string,
request: ObsltPplBusinessSaveRequest,
): Promise<ObsltPplBusiness> =>
putData(`${API_PATH}/${encodeURIComponent(id)}`, request) as Promise<ObsltPplBusiness>;
putData(
`${API_PATH}/${encodeURIComponent(id)}`,
request,
) as Promise<ObsltPplBusiness>;
/** 수정 화면에서 선택 사업을 논리삭제한다. */
export const deleteObsltPplBusiness = async (id: string): Promise<void> => {
@@ -72,4 +81,6 @@ export const deleteObsltPplBusiness = async (id: string): Promise<void> => {
export const uploadObsltPplExcel = async (
rows: ObsltPplBusinessSaveRequest[],
): Promise<ObsltPplImportResult> =>
unwrapData<ObsltPplImportResult>(await postRawJson(`${API_PATH}/excel/upload`, rows));
unwrapData<ObsltPplImportResult>(
await postRawJson(`${API_PATH}/excel/upload`, rows),
);
@@ -1,4 +1,5 @@
import * as XLSX from "xlsx";
import XlsxPopulate from "xlsx-populate/browser/xlsx-populate";
import { formatDate } from "../../common/util";
import type { ObsltPplBusiness, ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types";
@@ -35,6 +36,13 @@ const FIRST_DATA_COLUMN_INDEX = 1;
const DATA_COLUMN_COUNT = 21;
const DOWNLOAD_FILENAME = "노후하수관로 정밀조사 및 정비사업 현황.xlsx";
/** API 문자열에 남은 Excel 개행 이스케이프를 실제 줄바꿈으로 정리한다. */
const excelTextValue = (value: unknown) =>
String(value ?? "")
.replace(/_x000d_/gi, "")
.replace(/\r\n?/g, "\n")
.trim();
/** 줄바꿈과 공백이 포함된 병합 헤더를 비교 가능한 문자열로 정규화한다. */
const normalizeHeader = (value: string) => value.replace(/\s+/g, "").trim();
@@ -179,14 +187,16 @@ export const parseObsltPplExcel = async (file: File): Promise<ObsltPplBusinessSa
const downloadHeaderRows = (contract: SheetContract) => {
const rows = Array.from({ length: DATA_START_ROW_INDEX }, () => Array<string | number>(22).fill(""));
const topHeaders = [
"순번", "정밀조사차수", contract.businessCodeHeader, "시도", "시군", "환경청", "사업명",
"추진현황", "착수년도", "준공(예정)년도", contract.extensionHeader, "", "", "",
contract.progressHeader, "", "사업추진부진사유", "사업담당자(지자체)", "", "비고", "",
"순번", "정밀\n조사\n차수", contract.businessCodeHeader.replace("사업코드", "\n사업코드"),
"시도", "시군", "환경청", "사업명", "추진현황", "착수\n년도", "준공(예정)\n년도",
contract.extensionHeader, "", "", "", contract.progressHeader.replace("추진현황", " 추진현황"),
"", "사업추진 부진 사유", "사업 담당자(지자체)", "", "비고", "",
];
const bottomHeaders = [
"", "", "", "", "", "", "", "", "", "", "계획연장(km)", "상반기(예상)연장(km)",
"잔여연장(km)", "증빙서류제출여부", contract.startDateHeader, "준공(예정)일자(yyyy.mm.dd)",
"", "이름", "연락처", "", "공단의견",
"잔여연장(km)", "증빙서류\n제출여부", contract.startDateHeader.replace("(yyyy.mm.dd)", "\n(yyyy.mm.dd)"),
"준공(예정)일자\n(yyyy.mm.dd)", "", "이름", "연락처",
"(필요시 사업 담당자 추가 작성)", "공단의견",
];
topHeaders.forEach((value, index) => { rows[5]![index + FIRST_DATA_COLUMN_INDEX] = value; });
bottomHeaders.forEach((value, index) => { rows[6]![index + FIRST_DATA_COLUMN_INDEX] = value; });
@@ -197,26 +207,26 @@ const downloadHeaderRows = (contract: SheetContract) => {
const downloadDataRow = (row: ObsltPplBusiness, index: number): Array<string | number> => [
"",
index + 1,
row.prcisExmnCycl ?? "",
row.bizCd ?? "",
row.ctpvNm ?? "",
row.sigunNm ?? "",
row.envoNm ?? "",
row.bizNm ?? "",
row.prplPrst ?? "",
row.bgngYr ?? "",
row.cmcnYr ?? "",
excelTextValue(row.prcisExmnCycl),
excelTextValue(row.bizCd),
excelTextValue(row.ctpvNm),
excelTextValue(row.sigunNm),
excelTextValue(row.envoNm),
excelTextValue(row.bizNm),
excelTextValue(row.prplPrst),
excelTextValue(row.bgngYr),
excelTextValue(row.cmcnYr),
row.planPrlg ?? "",
row.frhfyrExpcPrlg ?? "",
row.rmndPrlg ?? "",
row.evdncDcmntSbmsnStts ?? "",
excelTextValue(row.evdncDcmntSbmsnStts),
formatDate(row.bgngYmd),
formatDate(row.cmcnYmd),
row.bizPrplDlyRsnCn ?? "",
row.bizPicNm ?? "",
row.bizPicCnpl ?? "",
row.rmrkCn ?? "",
row.pbcpOpnnCn ?? "",
excelTextValue(row.bizPrplDlyRsnCn),
excelTextValue(row.bizPicNm),
excelTextValue(row.bizPicCnpl),
excelTextValue(row.rmrkCn),
excelTextValue(row.pbcpOpnnCn),
];
/** 조회 결과를 정밀조사·정비사업 두 시트의 재업로드 가능한 위치 구조로 생성한다. */
@@ -224,17 +234,123 @@ export const buildObsltPplWorkbook = (businesses: ObsltPplBusiness[]): XLSX.Work
const workbook = XLSX.utils.book_new();
SHEET_CONTRACTS.forEach((contract) => {
const data = businesses.filter((row) => row.bizSe === contract.sheetName);
const headerRows = downloadHeaderRows(contract);
headerRows[1]![2] =
contract.sheetName === "정밀조사"
? "노후하수관로 정밀조사(2차) 사업추진 현황"
: "노후하수관로 정비사업 사업추진 현황";
headerRows[4]![1] = "조회 건수";
headerRows[4]![4] = `${data.length.toLocaleString("ko-KR")}건`;
headerRows[4]![7] = `${contract.extensionHeader} 합계`;
headerRows[4]![11] =
Math.round(
data.reduce((sum, row) => sum + (row.planPrlg ?? 0), 0) * 1000,
) / 1000;
const worksheet = XLSX.utils.aoa_to_sheet([
...downloadHeaderRows(contract),
...headerRows,
...data.map(downloadDataRow),
]);
worksheet["!cols"] = Array.from({ length: 22 }, (_, index) => ({ wch: index === 7 ? 30 : 16 }));
worksheet["!merges"] = [
{ s: { r: 1, c: 2 }, e: { r: 2, c: 10 } },
{ s: { r: 4, c: 1 }, e: { r: 4, c: 3 } },
{ s: { r: 4, c: 4 }, e: { r: 4, c: 6 } },
{ s: { r: 4, c: 7 }, e: { r: 4, c: 10 } },
{ s: { r: 4, c: 11 }, e: { r: 4, c: 13 } },
...[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 17].map((column) => ({
s: { r: 5, c: column },
e: { r: 6, c: column },
})),
{ s: { r: 5, c: 11 }, e: { r: 5, c: 13 } },
{ s: { r: 5, c: 15 }, e: { r: 5, c: 16 } },
{ s: { r: 5, c: 18 }, e: { r: 5, c: 19 } },
];
const widths = [
3, 7, 9, 16, 12, 12, 16, 34, 10, 9, 11, 13, 16, 13, 11, 16, 16,
34, 11, 16, 34, 24,
];
worksheet["!cols"] = widths.map((wch) => ({ wch }));
XLSX.utils.book_append_sheet(workbook, worksheet, contract.sheetName);
});
return workbook;
};
/** 생성한 두 시트 workbook을 브라우저 다운로드로 저장한다. */
export const downloadObsltPplExcel = (businesses: ObsltPplBusiness[]) => {
XLSX.writeFileXLSX(buildObsltPplWorkbook(businesses), DOWNLOAD_FILENAME);
/** 확정 원본의 제목·병합 헤더·구분색과 고정 본문 높이를 적용해 브라우저 다운로드를 시작한다. */
export const downloadObsltPplExcel = async (
businesses: ObsltPplBusiness[],
) => {
const workbook = buildObsltPplWorkbook(businesses);
const data = XLSX.write(workbook, { type: "array", bookType: "xlsx" });
const styledWorkbook = await XlsxPopulate.fromDataAsync(data);
SHEET_CONTRACTS.forEach((contract) => {
const rowCount = businesses.filter(
(business) => business.bizSe === contract.sheetName,
).length;
const styledSheet = styledWorkbook.sheet(contract.sheetName);
styledSheet.range("C2:K3").style({
bold: true,
fontFamily: "맑은 고딕",
fontSize: 16,
horizontalAlignment: "center",
verticalAlignment: "center",
border: true,
});
styledSheet.range("B5:N5").style({
bold: true,
fontFamily: "맑은 고딕",
fontSize: 10,
horizontalAlignment: "center",
verticalAlignment: "center",
fill: "FFF2CC",
border: true,
});
styledSheet.range("B6:V7").style({
bold: true,
fontFamily: "맑은 고딕",
fontSize: 10,
horizontalAlignment: "center",
verticalAlignment: "center",
wrapText: true,
border: true,
});
styledSheet.range("B6:H7").style("fill", "B4C6E7");
styledSheet.range("I6:Q7").style("fill", "FFF2CC");
styledSheet.range("R6:V7").style("fill", "FCE4D6");
styledSheet.row(2).height(26);
styledSheet.row(3).height(26);
styledSheet.row(5).height(22);
styledSheet.row(6).height(30);
styledSheet.row(7).height(34);
if (rowCount > 0) {
const dataStartRow = DATA_START_ROW_INDEX + 1;
const dataEndRow = DATA_START_ROW_INDEX + rowCount;
styledSheet.range(`B${dataStartRow}:V${dataEndRow}`).style({
fontFamily: "맑은 고딕",
fontSize: 10,
horizontalAlignment: "center",
verticalAlignment: "center",
wrapText: false,
border: true,
});
styledSheet
.range(`L${dataStartRow}:N${dataEndRow}`)
.style("numberFormat", "#,##0.###");
["H", "R", "U", "V"].forEach((column) =>
styledSheet
.range(`${column}${dataStartRow}:${column}${dataEndRow}`)
.style({ horizontalAlignment: "left", wrapText: true }),
);
for (let row = dataStartRow; row <= dataEndRow; row += 1)
styledSheet.row(row).height(22);
}
});
const blob = await styledWorkbook.outputAsync();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = DOWNLOAD_FILENAME;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
};
@@ -8,7 +8,9 @@ import { useRouter, useSearchParams } from "next/navigation";
import {
StandardDataTable,
StandardTableSortHeader,
getStandardRowNumber,
type UiTableSort,
} from "@common/business_components";
import {
Button,
@@ -183,12 +185,32 @@ export function ObsltPplListPage() {
const [search, setSearch] = useState<ObsltPplSearch>(initialSearch);
const [appliedSearch, setAppliedSearch] =
useState<ObsltPplSearch>(initialSearch);
const [sort, setSort] = useState<UiTableSort>();
/** 현재 선택한 시도에 속한 시군구만 선택 후보로 제공한다. */
const availableSgg = useMemo(() => {
return buildObsltPplRepresentativeSggOptions(sgg, ctpvCd);
}, [ctpvCd, sgg]);
/** 정렬 변경 시 동일 검색조건으로 서버 첫 페이지를 다시 조회한다. */
const handleSortChange = useCallback(
(nextSort?: UiTableSort) => {
setSort(nextSort);
setPage(1);
const queryString = createListQueryString(
1,
appliedSearch,
appliedCtpvCd,
appliedSggCd,
);
router.replace(
`/oper/obsltPplBiz${queryString ? `?${queryString}` : ""}`,
{ scroll: false },
);
},
[appliedCtpvCd, appliedSearch, appliedSggCd, router],
);
/** 공통 DataTable이 표시할 22개 업무 컬럼과 폭·정렬 규칙을 구성한다. */
const columns = useMemo<ColumnDef<ObsltPplTableRow>[]>(
() =>
@@ -196,7 +218,19 @@ export function ObsltPplListPage() {
id: column.id,
accessorFn: (row) => row[column.id],
header: () => (
<span className="whitespace-pre-line">{column.title}</span>
<StandardTableSortHeader
label={column.title.replace(/\n/g, " ")}
title={
<span className="whitespace-pre-line">{column.title}</span>
}
sortKey={
column.id === "rowNumber"
? "obsltSwagePplBizSn"
: String(column.id)
}
sort={sort}
onSortChange={handleSortChange}
/>
),
cell: ({ getValue }) => {
const displayValue = formatTableValue(getValue());
@@ -214,7 +248,7 @@ export function ObsltPplListPage() {
align: column.align ?? "center",
},
})),
[],
[handleSortChange, sort],
);
const pagination: PaginationState = {
pageIndex: page - 1,
@@ -258,11 +292,17 @@ export function ObsltPplListPage() {
/** 확정 검색조건과 페이지를 서버 요청 파라미터로 변환해 목록을 갱신한다. */
const load = useCallback(
async (targetPage = page, targetSearch = appliedSearch) => {
async (
targetPage = page,
targetSearch = appliedSearch,
nextSort: UiTableSort | undefined = sort,
) => {
const params = new URLSearchParams({
page: String(Math.max(targetPage - 1, 0)),
size: String(DEFAULT_LIST_SIZE),
sort: "obsltSwagePplBizSn,desc",
sort: nextSort
? `${nextSort.key},${nextSort.direction}`
: "obsltSwagePplBizSn,desc",
});
Object.entries(targetSearch).forEach(([key, value]) => {
if (value.trim()) params.set(key, value.trim());
@@ -295,7 +335,7 @@ export function ObsltPplListPage() {
setLoading(false);
}
},
[appliedSearch, page],
[appliedSearch, page, sort],
);
useEffect(() => {
@@ -368,8 +408,11 @@ export function ObsltPplListPage() {
Object.entries(appliedSearch).forEach(([key, value]) => {
if (value.trim()) params.set(key, value.trim());
});
const exportRows = await getAllObsltPplBusinesses(params);
downloadObsltPplExcel(exportRows);
const exportRows = await getAllObsltPplBusinesses(
params,
sort ? `${sort.key},${sort.direction}` : undefined,
);
await downloadObsltPplExcel(exportRows);
} catch (error) {
setNotice(
error instanceof Error ? error.message : "엑셀을 내려받지 못했습니다.",
@@ -407,6 +450,7 @@ export function ObsltPplListPage() {
setSggCd("0");
setAppliedCtpvCd("0");
setAppliedSggCd("0");
setSort(undefined);
setNotice("");
setPage(1);
router.replace("/oper/obsltPplBiz?bizSe=정밀조사", { scroll: false });
@@ -5,27 +5,27 @@ export const windowPopupDefinitions = {
key: "cfmtnBgt",
title: "최종확정예산사업",
width: 1400,
height: 720,
height: 900,
requiredParams: ["bizSe"],
},
exmnBusinessRegister: {
key: "exmnBusinessRegister",
title: "조사사업 등록",
width: 1000,
height: 620,
height: 720,
},
exmnResultRegister: {
key: "exmnResultRegister",
title: "조사 결과 업로드",
width: 1000,
height: 650,
width: 1100,
height: 760,
requiredParams: ["exmnBizId"],
},
exmnSurveyDetail: {
key: "exmnSurveyDetail",
title: "조사 상세현황",
width: 1500,
height: 650,
height: 820,
requiredParams: ["exmnBizId", "trtdstId", "trtdstNm", "jobType"],
},
dredgingRegister: {
@@ -51,8 +51,8 @@ export const windowPopupDefinitions = {
oldPpUpload: {
key: "oldPpUpload",
title: "노후 하수관로 정비사업 등록",
width: 860,
height: 360,
width: 1000,
height: 420,
},
oldPpTable: {
key: "oldPpTable",
@@ -64,8 +64,8 @@ export const windowPopupDefinitions = {
floodPreUpload: {
key: "floodPreUpload",
title: "도시침수예방사업 등록",
width: 860,
height: 360,
width: 1000,
height: 420,
},
floodPreTable: {
key: "floodPreTable",
@@ -138,30 +138,30 @@ export const windowPopupDefinitions = {
agedPipePopup: {
key: "agedPipePopup",
title: "노후 하수관로 목록",
width: 850,
height: 600,
width: 1160,
height: 820,
placement: "browser-left",
alignTop: true,
offsetLeft: 0,
offsetTop: 200,
offsetTop: 70,
},
sinkholePopup: {
key: "sinkholePopup",
title: "지반침하발생현황",
width: 1380,
height: 820,
height: 960,
},
sbmrgDngrMapPopup: {
key: "sbmrgDngrMapPopup",
title: "침수위험지도 목록",
width: 1160,
height: 720,
height: 820,
},
floodDngrMapPopup: {
key: "floodDngrMapPopup",
title: "도시침수지도 목록",
width: 1160,
height: 720,
height: 820,
},
pipeAnalysisPopup: {
key: "pipeAnalysisPopup",
@@ -172,14 +172,14 @@ export const windowPopupDefinitions = {
pipeAnalysisDetailPopup: {
key: "pipeAnalysisDetailPopup",
title: "관로현황",
width: 1180,
height: 820,
width: 1280,
height: 900,
},
repairHistoryPopup: {
key: "repairHistoryPopup",
title: "보수이력",
width: 1260,
height: 760,
height: 820,
requiredParams: ["zoneId"],
},
fldngPredcPrst: {
@@ -192,7 +192,7 @@ export const windowPopupDefinitions = {
key: "agedPipeStatusByCtpvPopup",
title: "시도별 노후관로현황",
width: 1160,
height: 790,
height: 820,
},
fldngHstryPopup: {
key: "fldngHstryPopup",
@@ -13,6 +13,7 @@ import {
getStandardRowNumber,
type ScrollMode,
StandardDataTable,
StandardTableSortHeader,
type UiHeadProps,
} from "@common/business_components";
import type { UiTableSort } from "@common/business_components/ui/table";
@@ -95,27 +96,13 @@ export default function MapPopupDataTable<T extends Record<string, unknown>>({
const title = ROW_NUMBER_IDS.has(head.id) ? "순번" : head.title;
if (!head.sortable || !onSortChange) return title;
const key = head.sortKey ?? head.id;
const direction = sort?.key === key ? sort.direction : undefined;
return (
<button
type="button"
className="inline-flex w-full items-center justify-center gap-1.5"
aria-label={`${title} 정렬`}
onClick={() => {
if (direction === undefined) {
onSortChange({ key, direction: "asc" });
} else if (direction === "asc") {
onSortChange({ key, direction: "desc" });
} else {
onSortChange(undefined);
}
}}
>
<span>{title}</span>
<span className="text-[8px] leading-none" aria-hidden="true">
{direction === "asc" ? "▲" : direction === "desc" ? "▼" : "↕"}
</span>
</button>
<StandardTableSortHeader
label={title}
sortKey={key}
sort={sort}
onSortChange={onSortChange}
/>
);
},
cell: ({ row, getValue }) => {
+87 -59
View File
@@ -8,6 +8,7 @@ type ExcelDownloadWithHeaderProps = {
};
type ExcelRow = Record<string, unknown>;
const EXCEL_DATA_ROWS_PER_SHEET = 500_000;
// 엑셀 시트명 제한 문자와 최대 길이를 정리해 다운로드 시 런타임 오류를 방지한다.
const normalizeSheetName = (sheetName: string | undefined) => {
@@ -15,6 +16,18 @@ const normalizeSheetName = (sheetName: string | undefined) => {
return normalized.slice(0, 31) || "Sheet1";
};
/** 다중 시트일 때 접미사가 잘리지 않도록 시트명 길이를 함께 조정한다. */
const buildSheetName = (
baseName: string | undefined,
sheetIndex: number,
sheetCount: number,
) => {
const normalizedBase = normalizeSheetName(baseName);
if (sheetCount === 1) return normalizedBase;
const suffix = `_${sheetIndex + 1}`;
return `${normalizedBase.slice(0, 31 - suffix.length)}${suffix}`;
};
// 한글을 두 글자 폭으로 계산해 내려받은 파일의 기본 열 너비를 읽기 좋게 맞춘다.
const getDisplayWidth = (value: unknown) =>
Array.from(String(value ?? "")).reduce(
@@ -38,7 +51,9 @@ export const downloadExcelWithKoreanHeaders = async ({
throw new Error("엑셀 다운로드는 브라우저에서만 사용할 수 있습니다.");
}
const dataRows = (Array.isArray(data) ? data : [data]) as unknown as ExcelRow[];
const dataRows = (Array.isArray(data)
? data
: [data]) as unknown as ExcelRow[];
if (dataRows.length === 0) {
throw new Error("데이터가 없습니다.");
}
@@ -49,71 +64,84 @@ export const downloadExcelWithKoreanHeaders = async ({
const koreanHeaders = englishHeaders.map(
(header) => headerMapping[header] || header,
);
const worksheetRows = [
koreanHeaders,
...dataRows.map((row) => englishHeaders.map((header) => row[header])),
];
const worksheet = XLSX.utils.aoa_to_sheet(worksheetRows);
// SheetJS가 지원하는 범위에서는 기존 공통 다운로드와 유사한 헤더 스타일을 적용한다.
const decodedHeaderRange = XLSX.utils.decode_range(headerRange);
for (
let rowIndex = decodedHeaderRange.s.r;
rowIndex <= decodedHeaderRange.e.r;
rowIndex += 1
) {
const workbook = XLSX.utils.book_new();
const sheetCount = Math.max(
1,
Math.ceil(dataRows.length / EXCEL_DATA_ROWS_PER_SHEET),
);
for (let sheetIndex = 0; sheetIndex < sheetCount; sheetIndex += 1) {
const sheetRows = dataRows.slice(
sheetIndex * EXCEL_DATA_ROWS_PER_SHEET,
(sheetIndex + 1) * EXCEL_DATA_ROWS_PER_SHEET,
);
const worksheetRows = [
koreanHeaders,
...sheetRows.map((row) => englishHeaders.map((header) => row[header])),
];
const worksheet = XLSX.utils.aoa_to_sheet(worksheetRows);
// SheetJS가 지원하는 범위에서는 기존 공통 다운로드와 유사한 헤더 스타일을 적용한다.
for (
let columnIndex = decodedHeaderRange.s.c;
columnIndex <= decodedHeaderRange.e.c;
columnIndex += 1
let rowIndex = decodedHeaderRange.s.r;
rowIndex <= decodedHeaderRange.e.r;
rowIndex += 1
) {
const cellAddress = XLSX.utils.encode_cell({
r: rowIndex,
c: columnIndex,
});
const headerCell = worksheet[cellAddress] as
| (typeof worksheet[string] & { s?: Record<string, unknown> })
| undefined;
if (headerCell) {
headerCell.s = {
fill: { patternType: "solid", fgColor: { rgb: "9BC1E7" } },
font: { bold: true, sz: 13 },
alignment: {
horizontal: "center",
vertical: "center",
wrapText: true,
},
border: {
top: { style: "thin", color: { rgb: "FFFFFF" } },
bottom: { style: "thin", color: { rgb: "FFFFFF" } },
left: { style: "thin", color: { rgb: "FFFFFF" } },
right: { style: "thin", color: { rgb: "FFFFFF" } },
},
};
for (
let columnIndex = decodedHeaderRange.s.c;
columnIndex <= decodedHeaderRange.e.c;
columnIndex += 1
) {
const cellAddress = XLSX.utils.encode_cell({
r: rowIndex,
c: columnIndex,
});
const headerCell = worksheet[cellAddress] as
| ((typeof worksheet)[string] & { s?: Record<string, unknown> })
| undefined;
if (headerCell) {
headerCell.s = {
fill: { patternType: "solid", fgColor: { rgb: "9BC1E7" } },
font: { bold: true, sz: 13 },
alignment: {
horizontal: "center",
vertical: "center",
wrapText: true,
},
border: {
top: { style: "thin", color: { rgb: "FFFFFF" } },
bottom: { style: "thin", color: { rgb: "FFFFFF" } },
left: { style: "thin", color: { rgb: "FFFFFF" } },
right: { style: "thin", color: { rgb: "FFFFFF" } },
},
};
}
}
}
worksheet["!autofilter"] = { ref: headerRange };
if (sheetCount === 1) {
worksheet["!merges"] = mergeRanges.map((range) =>
XLSX.utils.decode_range(range),
);
}
worksheet["!cols"] = englishHeaders.map((header, columnIndex) => {
const sampledValues = sheetRows
.slice(0, 200)
.map((row) => row[header])
.concat(koreanHeaders[columnIndex]);
const width = Math.max(...sampledValues.map(getDisplayWidth), 10);
return { wch: Math.min(width + 2, 40) };
});
XLSX.utils.book_append_sheet(
workbook,
worksheet,
buildSheetName(sheetNameArray[0], sheetIndex, sheetCount),
);
}
worksheet["!autofilter"] = { ref: headerRange };
worksheet["!merges"] = mergeRanges.map((range) =>
XLSX.utils.decode_range(range),
);
worksheet["!cols"] = englishHeaders.map((header, columnIndex) => {
const sampledValues = dataRows
.slice(0, 200)
.map((row) => row[header])
.concat(koreanHeaders[columnIndex]);
const width = Math.max(...sampledValues.map(getDisplayWidth), 10);
return { wch: Math.min(width + 2, 40) };
});
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(
workbook,
worksheet,
normalizeSheetName(sheetNameArray[0]),
);
const normalizedFileName = fileName.toLowerCase().endsWith(".xlsx")
? fileName
: `${fileName}.xlsx`;
+48
View File
@@ -0,0 +1,48 @@
type FetchAllPagesOptions<TResponse, TItem> = {
fetchPage: (page: number, size: number) => Promise<TResponse>;
getItems: (response: TResponse) => TItem[];
getTotal: (response: TResponse) => number;
batchSize?: number;
};
export type FetchAllPagesResult<TItem> = {
items: TItem[];
total: number;
};
const DEFAULT_BATCH_SIZE = 2000;
const MAX_BATCH_COUNT = 10000;
/**
* 서버의 Pageable 최대 크기에 의존하지 않고 모든 페이지를 순회한다.
* 화면 전체건수와 Excel 데이터 행 수가 달라지는 문제를 공통으로 방지한다.
*/
export const fetchAllPages = async <TResponse, TItem>({
fetchPage,
getItems,
getTotal,
batchSize = DEFAULT_BATCH_SIZE,
}: FetchAllPagesOptions<TResponse, TItem>): Promise<
FetchAllPagesResult<TItem>
> => {
const normalizedBatchSize = Math.max(1, Math.trunc(batchSize));
const items: TItem[] = [];
let total = 0;
for (let page = 0; page < MAX_BATCH_COUNT; page += 1) {
const response = await fetchPage(page, normalizedBatchSize);
const pageItems = getItems(response);
if (page === 0) total = Math.max(0, Number(getTotal(response) ?? 0));
items.push(...pageItems);
if (pageItems.length === 0 || items.length >= total) break;
}
if (items.length !== total) {
throw new Error(
`전체 ${total.toLocaleString("ko-KR")}건 중 ${items.length.toLocaleString("ko-KR")}건만 조회되어 Excel 생성을 중단했습니다.`,
);
}
return { items, total };
};
@@ -0,0 +1,19 @@
"use client";
import { useEffect } from "react";
import { setBusinessUserMessageHandler } from "@common/business_components";
import { useAlertStore } from "../lib/use-alert-store";
/** 공통 비즈니스 컴포넌트의 사용자 알림을 SN 공통 메시지 다이얼로그로 연결한다. */
export function BusinessUserMessageBridge() {
const setMessage = useAlertStore((state) => state.setMessage);
useEffect(
() => setBusinessUserMessageHandler((message) => setMessage(message)),
[setMessage],
);
return null;
}