그리드 정렬기능
This commit is contained in:
@@ -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 }) => {
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user