Merge branch 'iipbs/sn/dev'
This commit is contained in:
+1
-1
@@ -228,7 +228,7 @@ VSCode나 별도 터미널에서 `sn` Next 개발 서버가 실행 중이면 `ne
|
|||||||
|
|
||||||
### 프론트엔드 실행 프로세스 관리
|
### 프론트엔드 실행 프로세스 관리
|
||||||
|
|
||||||
- 모든 세션·역할·문서/검증 섹션에 공통 D:/workspace/AGENTS.md의 테스트 임시 파일·결과 파일 정리 기준을 적용한다. 일회성 검증 스크립트·캡처·Network/서버 로그·원시 결과 파일은 최종 보고서에 요약 후 삭제한다. 정식 회귀 테스트·최종 산출물·사용자 원본/복구 백업은 유지한다. 사용자 dev server가 쓰는 .next·공유 캐시·node_modules는 임시 검증물이라는 이유로 포괄 삭제하지 않는다.
|
- 모든 세션·역할·문서/검증 섹션에 공통 D:/workspace/AGENTS.md의 테스트 파일 정리 기준을 적용한다. 에이전트가 테스트를 위해 만든 파일은 정식 회귀 테스트·재사용 가능한 소스·fixture·mock·임시 라우트를 포함해 위치와 관계없이 결과를 요약한 뒤 작업 종료 전에 삭제한다(2026-09-23 사용자 지시). 전용 빈 폴더와 끊어진 실행 명령도 정리한다. 명시 보존 요청 파일·최종 산출물·사용자 원본/복구 백업은 유지한다. 기존 타 작업자 테스트 자산은 삭제 권한을 먼저 확인한다. 사용자 dev server의 .next·공유 캐시·node_modules는 포괄 삭제하지 않는다.
|
||||||
|
|
||||||
- Codex 에이전트가 작업 또는 검증을 위해 직접 기동한 `next dev`, `dev:sn`, Turborepo dev와 기타 프론트엔드 장시간 실행 프로세스는 작업 종료 전에 반드시 종료한다.
|
- Codex 에이전트가 작업 또는 검증을 위해 직접 기동한 `next dev`, `dev:sn`, Turborepo dev와 기타 프론트엔드 장시간 실행 프로세스는 작업 종료 전에 반드시 종료한다.
|
||||||
- 셸, 백그라운드, 숨김 창 등 VSCode 밖에서 에이전트가 기동한 프로세스도 종료 대상이다.
|
- 셸, 백그라운드, 숨김 창 등 VSCode 밖에서 에이전트가 기동한 프로세스도 종료 대상이다.
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ import {
|
|||||||
AGED_PIPE_LIST_HEADLIST,
|
AGED_PIPE_LIST_HEADLIST,
|
||||||
AGED_PIPE_PAGE_SIZE,
|
AGED_PIPE_PAGE_SIZE,
|
||||||
buildAgedPipeCqlFilters,
|
buildAgedPipeCqlFilters,
|
||||||
|
getAgedPipeManagementName,
|
||||||
normalizeAgedPipeFilters,
|
normalizeAgedPipeFilters,
|
||||||
toDisplayPipeRow,
|
toDisplayPipeRow,
|
||||||
validateElapsedYears,
|
validateElapsedYears,
|
||||||
type AgedPipeFilters,
|
type AgedPipeFilters,
|
||||||
type AgedPipeRow,
|
type AgedPipeRow,
|
||||||
|
type AgedPipeManagementAgency,
|
||||||
} from "./agedPipeUtils";
|
} from "./agedPipeUtils";
|
||||||
import { downloadAgedPipeExcel } from "./agedPipeExport";
|
import { downloadAgedPipeExcel } from "./agedPipeExport";
|
||||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||||
@@ -53,6 +55,15 @@ export default function AgedPipePopupPage() {
|
|||||||
const [appliedFilters, setAppliedFilters] =
|
const [appliedFilters, setAppliedFilters] =
|
||||||
useState<AgedPipeFilters>(initialFilters);
|
useState<AgedPipeFilters>(initialFilters);
|
||||||
const [rows, setRows] = useState<AgedPipeRow[]>([]);
|
const [rows, setRows] = useState<AgedPipeRow[]>([]);
|
||||||
|
// 관리기관명 조회가 늦게 완료되어도 기존 목록을 재조회하지 않고 표시만 갱신한다.
|
||||||
|
const [managementAgencies, setManagementAgencies] = useState<AgedPipeManagementAgency[]>([]);
|
||||||
|
const displayRows = useMemo(
|
||||||
|
() => rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
mngInstNm: getAgedPipeManagementName(row, managementAgencies),
|
||||||
|
})),
|
||||||
|
[rows, managementAgencies],
|
||||||
|
);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -73,6 +84,19 @@ export default function AgedPipePopupPage() {
|
|||||||
if (sgg.length === 0) fetchSgg();
|
if (sgg.length === 0) fetchSgg();
|
||||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
// 기존 GIS 관리 화면과 같은 기관 목록을 사용하고, 실패 시 관로 조회는 유지한다.
|
||||||
|
getData("v1/fcltPpl/mngInsts")
|
||||||
|
.then((response) => {
|
||||||
|
if (active) setManagementAgencies(Array.isArray(response) ? response : []);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (active) alert("관리기관명을 불러오지 못해 관리기관 코드로 표시합니다.");
|
||||||
|
});
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [alert]);
|
||||||
|
|
||||||
const fetchRows = useCallback(
|
const fetchRows = useCallback(
|
||||||
async (page: number, nextFilters: AgedPipeFilters, size: number) => {
|
async (page: number, nextFilters: AgedPipeFilters, size: number) => {
|
||||||
const normalized = normalizeAgedPipeFilters(nextFilters);
|
const normalized = normalizeAgedPipeFilters(nextFilters);
|
||||||
@@ -247,31 +271,6 @@ export default function AgedPipePopupPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
|
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
|
||||||
경과년도
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
className="w-[12rem]"
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
autoComplete="off"
|
|
||||||
value={String(filters.elapsedYears ?? "")}
|
|
||||||
onChange={(event) => {
|
|
||||||
const digits = event.target.value.replace(/[^0-9]/g, "");
|
|
||||||
setFilters({
|
|
||||||
...filters,
|
|
||||||
elapsedYears:
|
|
||||||
digits === "" ? ("" as unknown as number) : Number(digits),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter") void handleSearch(1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||||
시도
|
시도
|
||||||
@@ -316,6 +315,31 @@ export default function AgedPipePopupPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||||
|
경과년도
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
className="w-[12rem]"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
autoComplete="off"
|
||||||
|
value={String(filters.elapsedYears ?? "")}
|
||||||
|
onChange={(event) => {
|
||||||
|
const digits = event.target.value.replace(/[^0-9]/g, "");
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
elapsedYears:
|
||||||
|
digits === "" ? ("" as unknown as number) : Number(digits),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") void handleSearch(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -341,7 +365,7 @@ export default function AgedPipePopupPage() {
|
|||||||
</PopupSearchPanel>
|
</PopupSearchPanel>
|
||||||
|
|
||||||
<MapPopupDataTable
|
<MapPopupDataTable
|
||||||
rows={rows as Array<AgedPipeRow & Record<string, unknown>>}
|
rows={displayRows}
|
||||||
headlist={AGED_PIPE_LIST_HEADLIST}
|
headlist={AGED_PIPE_LIST_HEADLIST}
|
||||||
title="노후관로 목록"
|
title="노후관로 목록"
|
||||||
total={total}
|
total={total}
|
||||||
|
|||||||
@@ -56,15 +56,34 @@ const normalizeRegionFilterCode = (value: unknown, prefixLength: number) => {
|
|||||||
|
|
||||||
export const AGED_PIPE_LIST_HEADLIST = [
|
export const AGED_PIPE_LIST_HEADLIST = [
|
||||||
{ id: "rowNumber", title: "순번", widthPercent: 6 },
|
{ id: "rowNumber", title: "순번", widthPercent: 6 },
|
||||||
{ id: "fcltMngNo", title: "시설관리번호", widthPercent: 18 },
|
{ id: "fcltMngNo", title: "시설관리번호", widthPercent: 16 },
|
||||||
|
{ id: "mngInstNm", title: "관리기관", widthPercent: 14 },
|
||||||
{ id: "instlYmd", title: "설치일자", widthPercent: 12 },
|
{ id: "instlYmd", title: "설치일자", widthPercent: 12 },
|
||||||
{ id: "pdmtVl", title: "관경(mm)", widthPercent: 10 },
|
|
||||||
{ id: "prlgVl", title: "연장(m)", widthPercent: 10 },
|
|
||||||
{ id: "usgCd", title: "용도", widthPercent: 12 },
|
|
||||||
{ id: "mqltCd", title: "재질", widthPercent: 18 },
|
|
||||||
{ id: "elapsedYears", title: "경과년도", widthPercent: 8, align: "right" },
|
{ id: "elapsedYears", title: "경과년도", widthPercent: 8, align: "right" },
|
||||||
|
{ id: "pdmtVl", title: "관경(mm)", widthPercent: 9 },
|
||||||
|
{ id: "prlgVl", title: "연장(m)", widthPercent: 9 },
|
||||||
|
{ id: "usgCd", title: "용도", widthPercent: 10 },
|
||||||
|
{ id: "mqltCd", title: "재질", widthPercent: 16 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 기존 관리기관 조회 API의 코드와 기관명만 목록 표시용으로 사용한다. */
|
||||||
|
export type AgedPipeManagementAgency = {
|
||||||
|
mngInstCd: string;
|
||||||
|
lwrInstNm?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 명칭이 없는 기관은 원본 코드를 표시하며, 행의 관리기관 코드는 변경하지 않는다. */
|
||||||
|
export const getAgedPipeManagementName = (
|
||||||
|
row: PipeSource,
|
||||||
|
agencies: AgedPipeManagementAgency[],
|
||||||
|
): string => {
|
||||||
|
const directName = String(row.mngInstNm ?? row.mng_inst_nm ?? "").trim();
|
||||||
|
if (directName && directName !== "-") return directName;
|
||||||
|
const code = String(row.mngInstCd ?? row.mng_inst_cd ?? "").trim();
|
||||||
|
const agency = agencies.find((item) => String(item.mngInstCd).trim() === code);
|
||||||
|
return String(agency?.lwrInstNm ?? "").trim() || code || "-";
|
||||||
|
};
|
||||||
|
|
||||||
export const AGED_PIPE_EXCEL_FIELDS = [
|
export const AGED_PIPE_EXCEL_FIELDS = [
|
||||||
"ftreSmbolCd",
|
"ftreSmbolCd",
|
||||||
"fcltMngNo",
|
"fcltMngNo",
|
||||||
|
|||||||
+2
-2
@@ -4,10 +4,10 @@
|
|||||||
*/
|
*/
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { getSnRowNumber } from "@/shared/lib/row-number";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Button, Input } from "@common/components";
|
import { Button, Input } from "@common/components";
|
||||||
import {
|
import {
|
||||||
getStandardRowNumber,
|
|
||||||
StandardDataTable,
|
StandardDataTable,
|
||||||
StandardPopupLayout,
|
StandardPopupLayout,
|
||||||
} from "@common/business_components";
|
} from "@common/business_components";
|
||||||
@@ -71,7 +71,7 @@ export default function AgedPipeStatusByCtpvPopupPage() {
|
|||||||
id: "rowNumber",
|
id: "rowNumber",
|
||||||
header: "순번",
|
header: "순번",
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
getStandardRowNumber({
|
getSnRowNumber({
|
||||||
total: rows.length,
|
total: rows.length,
|
||||||
rowIndex: row.index,
|
rowIndex: row.index,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export const toFloodDngrMapTableRows = (
|
|||||||
): FloodDngrMapTableRow[] =>
|
): FloodDngrMapTableRow[] =>
|
||||||
items.map((item, index) => ({
|
items.map((item, index) => ({
|
||||||
...item,
|
...item,
|
||||||
rowNo: total - page * pageSize - index,
|
rowNo: page * pageSize + index + 1,
|
||||||
ctpvNm: resolveAdministrativeName(item.ctpvCd, ctpvOptions, 2),
|
ctpvNm: resolveAdministrativeName(item.ctpvCd, ctpvOptions, 2),
|
||||||
sggNm: resolveAdministrativeName(item.sggCd, sggOptions, 5),
|
sggNm: resolveAdministrativeName(item.sggCd, sggOptions, 5),
|
||||||
sbmrgDpthlDisplay: toDepthRangeDisplay(
|
sbmrgDpthlDisplay: toDepthRangeDisplay(
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const GIS_UPLOAD_HISTORY_HEADLIST: UiHeadProps[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
/** 오류 원본 행번호와 공통 표의 역순 순번을 별도 열로 표시한다. */
|
/** 오류 원본 행번호와 공통 표의 오름차순 표시 순번을 별도 열로 표시한다. */
|
||||||
const GIS_VALIDATION_ERROR_HEADLIST: UiHeadProps[] = [
|
const GIS_VALIDATION_ERROR_HEADLIST: UiHeadProps[] = [
|
||||||
{ id: "no", title: "순번", widthPercent: 8 },
|
{ id: "no", title: "순번", widthPercent: 8 },
|
||||||
{ id: "sourceRowNo", title: "행번호", widthPercent: 10 },
|
{ id: "sourceRowNo", title: "행번호", widthPercent: 10 },
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export default function InftwtResultPopupPage() {
|
|||||||
return {
|
return {
|
||||||
...(formattedItem as Record<string, unknown>),
|
...(formattedItem as Record<string, unknown>),
|
||||||
rowNumber: Math.max(
|
rowNumber: Math.max(
|
||||||
totalElements - ((page - 1) * INFTWT_RESULT_PAGE_SIZE + index),
|
(page - 1) * INFTWT_RESULT_PAGE_SIZE + index + 1,
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
|
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ export default function PipeAnalysisPopupPage() {
|
|||||||
() =>
|
() =>
|
||||||
yearMaterialGroups.map((group, index) => ({
|
yearMaterialGroups.map((group, index) => ({
|
||||||
...group,
|
...group,
|
||||||
rowNo: yearMaterialGroups.length - index,
|
rowNo: index + 1,
|
||||||
})),
|
})),
|
||||||
[yearMaterialGroups],
|
[yearMaterialGroups],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export default function PipeAnalysisSummaryTable({
|
|||||||
if (summaryRow.kind === "flat") {
|
if (summaryRow.kind === "flat") {
|
||||||
const value =
|
const value =
|
||||||
head.id === "rowNo"
|
head.id === "rowNo"
|
||||||
? total - ((page - 1) * pageSize + summaryRow.sourceIndex)
|
? (page - 1) * pageSize + summaryRow.sourceIndex + 1
|
||||||
: summaryRow.value[head.id];
|
: summaryRow.value[head.id];
|
||||||
const onClick =
|
const onClick =
|
||||||
summaryRow.value.__cellFilters[head.id] && Number(value) > 0
|
summaryRow.value.__cellFilters[head.id] && Number(value) > 0
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export default function RdiiResultPopupPage() {
|
|||||||
return {
|
return {
|
||||||
...(formattedItem as Record<string, unknown>),
|
...(formattedItem as Record<string, unknown>),
|
||||||
rowNumber: Math.max(
|
rowNumber: Math.max(
|
||||||
totalElements - ((page - 1) * RDII_RESULT_PAGE_SIZE + index),
|
(page - 1) * RDII_RESULT_PAGE_SIZE + index + 1,
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
|
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export const toSbmrgDngrMapTableRows = (
|
|||||||
): SbmrgDngrMapTableRow[] =>
|
): SbmrgDngrMapTableRow[] =>
|
||||||
items.map((item, index) => ({
|
items.map((item, index) => ({
|
||||||
...item,
|
...item,
|
||||||
rowNo: total - page * pageSize - index,
|
rowNo: page * pageSize + index + 1,
|
||||||
ctpvNm: resolveAdministrativeNames(item.ctpvCd, ctpvOptions, 2),
|
ctpvNm: resolveAdministrativeNames(item.ctpvCd, ctpvOptions, 2),
|
||||||
sggNm: resolveAdministrativeNames(item.sggCd, sggOptions, 5),
|
sggNm: resolveAdministrativeNames(item.sggCd, sggOptions, 5),
|
||||||
floodWtlvFrqVlDisplay: toDisplay(item.floodWtlvFrqVl),
|
floodWtlvFrqVlDisplay: toDisplay(item.floodWtlvFrqVl),
|
||||||
|
|||||||
@@ -7692,6 +7692,7 @@ const Map = () => {
|
|||||||
detachedTreeToggleButton={true}
|
detachedTreeToggleButton={true}
|
||||||
showLayerPanelTitle={false}
|
showLayerPanelTitle={false}
|
||||||
sectionedTreePanel={true}
|
sectionedTreePanel={true}
|
||||||
|
showBulkLayerToggle={true}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
setIsPlaying={setIsPlaying as any}
|
setIsPlaying={setIsPlaying as any}
|
||||||
targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any}
|
targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any}
|
||||||
|
|||||||
@@ -405,7 +405,7 @@ const ReportTab: React.FC<ReportTabProps> = ({
|
|||||||
.slice(pageStart, pageEnd)
|
.slice(pageStart, pageEnd)
|
||||||
.map((item, index) => ({
|
.map((item, index) => ({
|
||||||
...item,
|
...item,
|
||||||
no: totalCount - (pageOffset + index),
|
no: pageOffset + index + 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
reportRows = await enrichReportRows(pagedReportData);
|
reportRows = await enrichReportRows(pagedReportData);
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export default function useBasicTab() {
|
|||||||
// 추가 처리 및 일련번호 추가
|
// 추가 처리 및 일련번호 추가
|
||||||
return {
|
return {
|
||||||
...formattedItem,
|
...formattedItem,
|
||||||
rowNumber: totalCount - ((curPage - 1) * 10 + index),
|
rowNumber: (curPage - 1) * 10 + index + 1,
|
||||||
scsYn:
|
scsYn:
|
||||||
item.scsYn === null || item.scsYn === undefined ? "N" : item?.scsYn, // scsYn이 null이면 'N'으로 변경
|
item.scsYn === null || item.scsYn === undefined ? "N" : item?.scsYn, // scsYn이 null이면 'N'으로 변경
|
||||||
mqltCd: matchedMqltCode ? matchedMqltCode.text : item?.mqltCd,
|
mqltCd: matchedMqltCode ? matchedMqltCode.text : item?.mqltCd,
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export default function useOperationTab() {
|
|||||||
return {
|
return {
|
||||||
...formattedItem,
|
...formattedItem,
|
||||||
rowNumber:
|
rowNumber:
|
||||||
totalElements - ((curPage - 1) * OPERATION_TABLE_SIZE + index),
|
(curPage - 1) * OPERATION_TABLE_SIZE + index + 1,
|
||||||
bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd,
|
bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -233,7 +233,7 @@ export default function useOperationTab() {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...formattedItem,
|
...formattedItem,
|
||||||
rowNumber: totalDetailElements - detailIndex,
|
rowNumber: detailIndex + 1,
|
||||||
usgCd: usgCdObj ? usgCdObj.text : item.usgCd,
|
usgCd: usgCdObj ? usgCdObj.text : item.usgCd,
|
||||||
mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd,
|
mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd,
|
||||||
swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : "-",
|
swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : "-",
|
||||||
@@ -325,8 +325,7 @@ export default function useOperationTab() {
|
|||||||
return {
|
return {
|
||||||
...formattedItem,
|
...formattedItem,
|
||||||
rowNumber:
|
rowNumber:
|
||||||
totalElements -
|
(detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex + 1,
|
||||||
((detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex),
|
|
||||||
usgCd: usgCdObj ? usgCdObj.text : item.usgCd,
|
usgCd: usgCdObj ? usgCdObj.text : item.usgCd,
|
||||||
mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd,
|
mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd,
|
||||||
swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : "-",
|
swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : "-",
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export default function useSearchModal() {
|
|||||||
return {
|
return {
|
||||||
...formattedItem,
|
...formattedItem,
|
||||||
rowNumber:
|
rowNumber:
|
||||||
totalElements - ((curPage - 1) * OPERATION_TABLE_SIZE + index),
|
(curPage - 1) * OPERATION_TABLE_SIZE + index + 1,
|
||||||
bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd,
|
bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ const Result = () => {
|
|||||||
|
|
||||||
const totalElements = Number(data?.totalElements ?? 0);
|
const totalElements = Number(data?.totalElements ?? 0);
|
||||||
|
|
||||||
// 전체 건수를 기준으로 현재 페이지의 순번을 내림차순 계산한다.
|
// 현재 페이지의 시작 위치를 기준으로 오름차순 표시 순번을 계산한다.
|
||||||
const dataWithRowNumber = (data.content || [])
|
const dataWithRowNumber = (data.content || [])
|
||||||
.map((item: any, index: number) => {
|
.map((item: any, index: number) => {
|
||||||
// 🎯 먼저 각 아이템을 포맷팅
|
// 🎯 먼저 각 아이템을 포맷팅
|
||||||
@@ -179,11 +179,11 @@ const Result = () => {
|
|||||||
(code) => code.val.trim() === item?.ctpvCd?.trim(),
|
(code) => code.val.trim() === item?.ctpvCd?.trim(),
|
||||||
);
|
);
|
||||||
const sggOne = sgg.find((code) => code.val === item.sggCd);
|
const sggOne = sgg.find((code) => code.val === item.sggCd);
|
||||||
// 추가 처리 및 전체 건수 기준 순번 추가
|
// 추가 처리 및 페이지 연속 순번 추가
|
||||||
return {
|
return {
|
||||||
...formattedItem,
|
...formattedItem,
|
||||||
rowNumber: Math.max(
|
rowNumber: Math.max(
|
||||||
totalElements - (((options?.page ?? curPage) - 1) * 10 + index),
|
((options?.page ?? curPage) - 1) * 10 + index + 1,
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
scsYn:
|
scsYn:
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ const Result = () => {
|
|||||||
|
|
||||||
const totalElements = Number(data?.totalElements ?? 0);
|
const totalElements = Number(data?.totalElements ?? 0);
|
||||||
|
|
||||||
// 전체 건수를 기준으로 현재 페이지의 순번을 내림차순 계산한다.
|
// 현재 페이지의 시작 위치를 기준으로 오름차순 표시 순번을 계산한다.
|
||||||
const dataWithRowNumber = (data.content || [])
|
const dataWithRowNumber = (data.content || [])
|
||||||
.map((item: any, index: number) => {
|
.map((item: any, index: number) => {
|
||||||
// 🎯 먼저 각 아이템을 포맷팅
|
// 🎯 먼저 각 아이템을 포맷팅
|
||||||
@@ -179,11 +179,11 @@ const Result = () => {
|
|||||||
(code) => code.val.trim() === item?.ctpvCd?.trim(),
|
(code) => code.val.trim() === item?.ctpvCd?.trim(),
|
||||||
);
|
);
|
||||||
const sggOne = sgg.find((code) => code.val === item.sggCd);
|
const sggOne = sgg.find((code) => code.val === item.sggCd);
|
||||||
// 추가 처리 및 전체 건수 기준 순번 추가
|
// 추가 처리 및 페이지 연속 순번 추가
|
||||||
return {
|
return {
|
||||||
...formattedItem,
|
...formattedItem,
|
||||||
rowNumber: Math.max(
|
rowNumber: Math.max(
|
||||||
totalElements - (((options?.page ?? curPage) - 1) * 10 + index),
|
((options?.page ?? curPage) - 1) * 10 + index + 1,
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
scsYn:
|
scsYn:
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
Chart as ChartJS,
|
|
||||||
CategoryScale,
|
|
||||||
LinearScale,
|
|
||||||
BarElement,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
type ChartOptions,
|
|
||||||
} from "chart.js";
|
|
||||||
import { Bar } from "react-chartjs-2";
|
|
||||||
|
|
||||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const labels = ["한강", "낙동강", "영산강", "금강"];
|
|
||||||
|
|
||||||
// 데이터: [기타, 오수관로, 우수관로]
|
|
||||||
const datasets = [
|
|
||||||
{
|
|
||||||
label: "기타",
|
|
||||||
data: [15481, 6959, 1938, 1967],
|
|
||||||
backgroundColor: "rgba(255, 178, 150, 0.9)", // 연한 주황(파스텔톤)
|
|
||||||
borderColor: "rgba(255,178,150,1)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "오수관로",
|
|
||||||
data: [15878, 16336, 5670, 8073],
|
|
||||||
backgroundColor: "rgba(180, 240, 190, 0.95)", // 연한 초록(파스텔톤)
|
|
||||||
borderColor: "rgba(180,240,190,1)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "우수관로",
|
|
||||||
data: [12892, 10491, 4280, 4838],
|
|
||||||
backgroundColor: "rgba(170, 210, 245, 0.95)", // 연한 파랑(파스텔톤)
|
|
||||||
borderColor: "rgba(170,210,245,1)",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 총합(라벨당) 계산
|
|
||||||
const totals = labels.map((_, i) =>
|
|
||||||
datasets.reduce((s, ds) => s + (ds.data[i] as number), 0),
|
|
||||||
);
|
|
||||||
|
|
||||||
const options: ChartOptions<"bar"> = {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
position: "top",
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
callbacks: {
|
|
||||||
label: (ctx) => `${ctx.dataset.label}: ${Number(ctx.parsed.y).toLocaleString()}km`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
stacked: true,
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
stacked: true,
|
|
||||||
beginAtZero: true,
|
|
||||||
ticks: {
|
|
||||||
callback: (value) => `${Number(value).toLocaleString()}km`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// 각 스택 위에 총합 표시
|
|
||||||
animation: {
|
|
||||||
onComplete: function (animation) {
|
|
||||||
const chart = animation.chart;
|
|
||||||
const ctx = chart.ctx;
|
|
||||||
const lastMeta = chart.getDatasetMeta(datasets.length - 1);
|
|
||||||
ctx.save();
|
|
||||||
ctx.fillStyle = "#111";
|
|
||||||
ctx.font = "gray bold 12px Arial";
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
|
|
||||||
totals.forEach((total, i) => {
|
|
||||||
const bar = lastMeta.data[i];
|
|
||||||
if (!bar) return;
|
|
||||||
const x = bar.x;
|
|
||||||
// y는 마지막 스택의 y 좌표(스택의 최상단)에서 조금 위로
|
|
||||||
const y = bar.y - 8;
|
|
||||||
ctx.fillText(`총 ${total.toLocaleString()}km`, x, y);
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.restore();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = { labels, datasets };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ width: "100%", maxWidth: 800, height: 360 }}>
|
|
||||||
<Bar options={options} data={data} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -425,7 +425,7 @@ const Page = () => {
|
|||||||
return buildElapsedYearRows(filteredRecords);
|
return buildElapsedYearRows(filteredRecords);
|
||||||
|
|
||||||
return filteredRecords.map((record, index) => ({
|
return filteredRecords.map((record, index) => ({
|
||||||
rowNumber: filteredRecords.length - index,
|
rowNumber: index + 1,
|
||||||
...record,
|
...record,
|
||||||
pipeLengthM: Number(record.pipeLengthM.toFixed(2)),
|
pipeLengthM: Number(record.pipeLengthM.toFixed(2)),
|
||||||
pipeMaterial: resolvePipeMaterialName(record.pipeMaterial),
|
pipeMaterial: resolvePipeMaterialName(record.pipeMaterial),
|
||||||
|
|||||||
@@ -17,13 +17,13 @@
|
|||||||
* - 첨부파일 삭제및 Soft Delete 삭제: DELETE /api/v1/atfl/attachments/{atflId} (attach-service)
|
* - 첨부파일 삭제및 Soft Delete 삭제: DELETE /api/v1/atfl/attachments/{atflId} (attach-service)
|
||||||
*/
|
*/
|
||||||
"use client";
|
"use client";
|
||||||
|
import { getSnRowNumber } from "@/shared/lib/row-number";
|
||||||
import { deleteData, useCookieAuth } from "@/components/lib/api";
|
import { deleteData, useCookieAuth } from "@/components/lib/api";
|
||||||
import { searchData } from "@/components/lib/common/apiHelpers";
|
import { searchData } from "@/components/lib/common/apiHelpers";
|
||||||
import { formatDate, handleDownload } from "@/components/lib/utils";
|
import { formatDate, handleDownload } from "@/components/lib/utils";
|
||||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||||
import {
|
import {
|
||||||
StandardDataTable,
|
StandardDataTable,
|
||||||
getStandardRowNumber,
|
|
||||||
} from "@common/business_components";
|
} from "@common/business_components";
|
||||||
import { Button, Input, Label } from "@common/components";
|
import { Button, Input, Label } from "@common/components";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
@@ -192,7 +192,7 @@ const Page = () => {
|
|||||||
const dataWithRowNumber = (data || []).map(
|
const dataWithRowNumber = (data || []).map(
|
||||||
(item: any, index: number) => ({
|
(item: any, index: number) => ({
|
||||||
...item,
|
...item,
|
||||||
rowNumber: getStandardRowNumber({
|
rowNumber: getSnRowNumber({
|
||||||
total,
|
total,
|
||||||
rowIndex: index,
|
rowIndex: index,
|
||||||
pageIndex: page - 1,
|
pageIndex: page - 1,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { getSnRowNumber } from "@/shared/lib/row-number";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -22,7 +23,6 @@ import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
|
|||||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
||||||
import { useAlertStore } from "@/shared/lib";
|
import { useAlertStore } from "@/shared/lib";
|
||||||
import {
|
import {
|
||||||
getStandardRowNumber,
|
|
||||||
StandardDataTable,
|
StandardDataTable,
|
||||||
} from "@common/business_components";
|
} from "@common/business_components";
|
||||||
import { Button, Input, Label } from "@common/components";
|
import { Button, Input, Label } from "@common/components";
|
||||||
@@ -125,7 +125,7 @@ const Page = () => {
|
|||||||
setRows(
|
setRows(
|
||||||
(result.data ?? []).map((item: OldPpRow, rowIndex: number) => ({
|
(result.data ?? []).map((item: OldPpRow, rowIndex: number) => ({
|
||||||
...item,
|
...item,
|
||||||
rowNumber: getStandardRowNumber({
|
rowNumber: getSnRowNumber({
|
||||||
total: result.total,
|
total: result.total,
|
||||||
rowIndex,
|
rowIndex,
|
||||||
pageIndex: targetPage - 1,
|
pageIndex: targetPage - 1,
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ function filterEmptyRows(data: ExcelRow[]) {
|
|||||||
function withDescendingRowNumber(rows: ExcelRow[]) {
|
function withDescendingRowNumber(rows: ExcelRow[]) {
|
||||||
return rows.map((row, index) => ({
|
return rows.map((row, index) => ({
|
||||||
...row,
|
...row,
|
||||||
rowNumber: rows.length - index,
|
rowNumber: index + 1,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { cookies } from "next/headers";
|
|||||||
import { AppProvider } from "./providers";
|
import { AppProvider } from "./providers";
|
||||||
import "../src/app/styles/base.css";
|
import "../src/app/styles/base.css";
|
||||||
import "../src/app/styles/components.css";
|
import "../src/app/styles/components.css";
|
||||||
|
import "../src/app/styles/grid-ellipsis.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "디지털 하수관로",
|
title: "디지털 하수관로",
|
||||||
@@ -35,7 +36,7 @@ export default async function RootLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="ko" data-theme={theme?.value}>
|
<html lang="ko" data-theme={theme?.value}>
|
||||||
<body>
|
<body data-sn-grid-ellipsis="true">
|
||||||
<AppProvider>{children}</AppProvider>
|
<AppProvider>{children}</AppProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# SN 전체 목록 그리드 말줄임표
|
||||||
|
|
||||||
|
> 2026-09-23 후속 정리: 사용자 요청에 따라 `scripts/grid-cell-tooltip.test.cjs`를 삭제했다. 아래 테스트 명령과 결과는 삭제 전 실행 이력이며 테스트 소스 보존 문구는 더 이상 적용하지 않는다. 구현 코드와 이 최종 보고서는 유지한다.
|
||||||
|
|
||||||
|
2026-09-23 사용자 요청: 노후 하수관로 정비사업 화면처럼 긴 내용이 행 높이를 늘리는 현상을 SN 전체 그리드에서 정리한다.
|
||||||
|
기존 원본 화면의 표 컬럼·업무값은 유지하고 이번 요청을 표시 방식 변경 근거로 적용했다.
|
||||||
|
|
||||||
|
## 변경
|
||||||
|
|
||||||
|
- app/layout.tsx: SN 전용 body 속성과 grid-ellipsis.css 등록.
|
||||||
|
- src/app/styles/grid-ellipsis.css: 헤더가 있는 목록 표의 일반 데이터 셀을 한 줄·overflow hidden·text-overflow ellipsis로 처리. 중첩 span/div의 whitespace-normal/pre-line도 정리.
|
||||||
|
- src/shared/ui/grid-cell-tooltip.tsx: 넘치는 셀에 pointerover/focusin 시 원문 title 제공. 기존 셀·자식 title은 우선 보존. 동적으로 교체된 셀의 이전 생성 title은 다시 계산.
|
||||||
|
- src/app/providers/app-provider.tsx: SN 문서와 포털의 표에서 툴팁 처리를 공통 사용.
|
||||||
|
- scripts/grid-cell-tooltip.test.cjs: 정식 회귀 테스트 3개.
|
||||||
|
|
||||||
|
table 레이아웃·열 너비·데이터·정렬·엑셀 생성은 변경하지 않았다. 원문을 잘라 저장하지 않고 CSS로만 생략한다.
|
||||||
|
thead가 없는 폼/상세 표, 입력창·버튼·체크박스·이미지·중첩 표를 가진 셀, rowspan/colspan 병합 셀, tfoot은 기존 표시를 유지한다.
|
||||||
|
예외적으로 원래 줄바꿈이 필요한 셀은 data-sn-cell-wrap 속성으로 제외할 수 있다.
|
||||||
|
공용 패키지와 다른 서비스는 수정하지 않았다. 앞선 순번·레이어 토글 변경도 보존했다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- TypeScript 검사 및 git diff --check 통과.
|
||||||
|
- 단위 테스트: CSS/JS 대상 셀 일치·입력 예외, 원문 툴팁 및 셀 재사용 시 갱신·정리, 기존 title 보존 — 3개 통과.
|
||||||
|
- 실행 중인 사용자 로컬 서버(3002)를 재시작하거나 종료하지 않고 HMR로 검증했다.
|
||||||
|
- 실제 StandardDataTable과 일반 HTML 표에 사진과 유사한 장문 테스트 데이터를 연결한 브라우저 확인:
|
||||||
|
- 긴 셀과 짧은 셀의 행 높이 동일(약 40.59px).
|
||||||
|
- 일반 td 및 block/whitespace-normal span 모두 한 줄 말줄임표 확인.
|
||||||
|
- 948자 원문이 DOM에 보존되고 생성 title이 원문과 일치함을 확인.
|
||||||
|
- 입력값 변경·버튼 클릭 횟수 증가 및 병합 합계 셀 보존 확인.
|
||||||
|
- 중첩 셀 렌더러의 테스트 표도 추가해 표시 경로를 확인했으나 전체 개별 화면을 실데이터로 전수 검증한 것은 아니다.
|
||||||
|
- 원본 데이터·API·DB 및 엑셀 출력 코드 변경 없음. 실제 업무 화면·엑셀 전체 회귀는 별도 확인이 필요하다.
|
||||||
|
|
||||||
|
## 정리 및 복구
|
||||||
|
|
||||||
|
임시 검증 페이지 1개를 삭제했다. 원시 캡처·응답 파일은 만들지 않았으며 정식 테스트·본 문서는 보존한다.
|
||||||
|
사용자 서버는 계속 실행 중이며 커밋·push·배포는 하지 않았다.
|
||||||
|
복구 시 이번 CSS import·body 속성·GridCellTooltip 등록 및 신규 파일만 제거한다. 다른 작업 변경은 되돌리지 않는다.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# SN 그리드 표시 순번 오름차순 통일
|
||||||
|
|
||||||
|
> 2026-09-23 후속 정리: 사용자 요청에 따라 `scripts/row-number.test.cjs`를 삭제했다. 아래 테스트 명령과 결과는 삭제 전 실행 이력이며 테스트 소스 보존 문구는 더 이상 적용하지 않는다. 구현 코드와 이 최종 보고서는 유지한다.
|
||||||
|
|
||||||
|
## 목적 및 기준
|
||||||
|
|
||||||
|
2026-09-23 사용자 지시: 하수관로 SN 전체 그리드의 표시 순번을 1, 2, 3…으로 통일한다.
|
||||||
|
서버 데이터 정렬, 업무 ID, 저장용 순번과 검색·페이지 계약은 변경하지 않는다.
|
||||||
|
|
||||||
|
- 페이지 목록: `(현재 페이지 - 1) × 페이지 크기 + 행 인덱스 + 1`.
|
||||||
|
- 0부터 시작하는 서버 페이지: `page × pageSize + index + 1`.
|
||||||
|
- 비페이지 목록·전체 데이터: `index + 1`.
|
||||||
|
- GIS 오류의 원본 행번호, 사업순번, 시설물 식별자, 합계·소계 및 병합 표의 빈 셀은 기존 의미를 보존한다.
|
||||||
|
|
||||||
|
원본 요구사항정의서.xlsx의 시트 구조와 화면설계서.pptx의 관련 화면 텍스트를 열람했다.
|
||||||
|
대표 근거는 슬라이드 35의 SFR-012-002 / SN_MN010302 GIS 관리 업로드 이력 목록, 슬라이드 43의 SFR-010-004 조사결과 등록이다.
|
||||||
|
원본 화면의 목록·업무 항목은 유지하며, 전체 표에 대한 오름차순 표시 정책은 이번 사용자 명시 지시를 적용한다.
|
||||||
|
|
||||||
|
## 수정 범위
|
||||||
|
|
||||||
|
앱의 기존 파일 26개와 SN 전용 순번 함수·정식 회귀 테스트 각 1개를 변경·추가했다.
|
||||||
|
공용 business_components의 기존 getStandardRowNumber 구현 및 다른 앱은 수정하지 않았다.
|
||||||
|
|
||||||
|
| 구분 | 변경 대상 |
|
||||||
|
| --- | --- |
|
||||||
|
| SN 전용 공통 계산 | src/shared/lib/row-number.ts의 getSnRowNumber |
|
||||||
|
| 공통 표 연결부 | MapPopupDataTable, ExmnStandardDataTable |
|
||||||
|
| 조사관리 | ExmnRegistrationPage, ExmnStatusDetailPanel, TrtdstManagementPage |
|
||||||
|
| 운영 목록·상세 | oldPp page/tableModal, floodPre page, dredging page, ObsltPplListPage, CtyFldngPrvtnBizListPage, CfmtnBgtPopupContent |
|
||||||
|
| I/I 분석 | ii/result, ii/rdiiResult, InftwtResultPopupPage, RdiiResultPopupPage |
|
||||||
|
| 지도 검색·상세 | useSearchModal, useOperationTab, useBasicTab, reportTab |
|
||||||
|
| 지도 분석 | floodDngrMapTypes, sbmrgDngrMapTypes, AgedPipeStatusByCtpvPopupPage, PipeAnalysisPopupPage, PipeAnalysisSummaryTable |
|
||||||
|
| GIS 관리 | 공통 표 경유 순번 변경 및 오류 원본 행번호와 구분하는 주석 정비 |
|
||||||
|
|
||||||
|
### 코드 변경 이유
|
||||||
|
|
||||||
|
기존 공통 함수는 `total - (pageIndex * pageSize + rowIndex)`로 전체 건수부터 역순 표시했다.
|
||||||
|
공용 패키지의 기본 동작을 바꾸면 다른 앱에 영향을 줄 수 있으므로 SN 전용 함수를 추가하고 SN 호출부 10개 파일을 연결했다.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function getSnRowNumber({ rowIndex, pageIndex = 0, pageSize = 0 }) {
|
||||||
|
return pageIndex * pageSize + rowIndex + 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
개별 화면에 남은 `total - offset - index`, `rows.length - index` 계산도 정순으로 변경했다.
|
||||||
|
이미 정순인 지반침하·침수이력·노후관로 등의 기존 데이터 가공은 유지하면서 공통 표시부에서 덮어쓰던 역순을 제거했다.
|
||||||
|
처리분구의 withDescendingRowNumbers는 실제 동작에 맞게 withAscendingRowNumbers로 변경했다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- `node --test scripts/row-number.test.cjs`: 4개 테스트 통과.
|
||||||
|
- 비페이지 1부터 시작, 전체 건수에 비의존.
|
||||||
|
- 1페이지 1~10, 2페이지 11~20, 마지막 부분 페이지 21~23.
|
||||||
|
- 페이지 크기 변경 및 검색 초기화.
|
||||||
|
- SN 전체 app/src 소스에서 공용 역순 함수와 기존 역순 산식 재유입 검사.
|
||||||
|
- `pnpm exec tsc --noEmit --incremental false --pretty false`: 변경 후 통과.
|
||||||
|
- `git diff --check`: 통과.
|
||||||
|
- 실데이터 화면: localhost:3002 로그인 후 침입수 결과 조회를 시도했으나 localhost:8883 백엔드 ECONNREFUSED로 조회 실패.
|
||||||
|
- 컴포넌트 브라우저 검증: 실제 MapPopupDataTable·ExmnStandardDataTable에 테스트 데이터 23건을 연결하여 1/2/3페이지 순번 확인. 데이터 순서 반전 시 순번 1부터, 검색 3건 전환 시 1~3 확인.
|
||||||
|
- 이 브라우저 시험은 테스트 데이터이며 실제 업무 데이터 전체 검증을 대체하지 않는다. 모든 개별 화면의 실데이터 회귀와 Excel 결과 전체 대조는 미완료다.
|
||||||
|
- Next 전체 빌드는 실행하지 않았다. API·DB·인증·환경설정·배포 및 데이터 정렬 조건은 수정하지 않았다.
|
||||||
|
|
||||||
|
## 정리 및 복구
|
||||||
|
|
||||||
|
- 일회성 브라우저 검증 페이지 1개는 확인 후 삭제했다. 화면 캡처·원시 응답 파일은 생성하지 않았다.
|
||||||
|
- 정식 회귀 테스트는 후속 검증용 소스이므로 보존한다.
|
||||||
|
- 직접 기동한 SN 개발서버(next dev PID 37840 및 하위 프로세스)는 종료했다. 사용자 서버는 종료하지 않았다.
|
||||||
|
- 커밋·push·개발서버 배포는 수행하지 않았다. 복구가 필요하면 이번 SN 변경 diff만 역적용하고 관련 없는 변경은 유지한다.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 노후관로 목록 조회조건 및 표시항목 변경
|
||||||
|
|
||||||
|
> 2026-09-23 후속 정리: 사용자 요청에 따라 `scripts/aged-pipe-list.test.cjs`를 삭제했다. 아래 테스트 명령과 결과는 삭제 전 실행 이력이며 테스트 소스 보존 문구는 더 이상 적용하지 않는다. 구현 코드와 이 최종 보고서는 유지한다.
|
||||||
|
|
||||||
|
## 목적과 근거
|
||||||
|
|
||||||
|
- 2026-09-23 사용자 요청: 조회조건을 시도, 시군구, 경과년도 순으로 변경하고 관리기관, 설치일자, 경과년도를 연속 배치한다.
|
||||||
|
- 원본 요구사항정의서 `3. (기능)요구사항정의 및 추적표` 87~90행, SFR-011-001~004 확인. 목록과 경과년도 조회는 88행 SFR-011-002와 연결된다.
|
||||||
|
- 원본 화면설계서 슬라이드 8, 화면 ID `SN_MN010102`, 노후관로관리 확인. 기존 필터는 경과년도/시도/시군구이며 관리기관 컬럼은 없다. 이번 명시 요청을 우선 적용했다.
|
||||||
|
- 원본 요구사항의 기본값 20년과 화면설계/현재 코드의 30년 차이는 기존 사항이다. 이번 요청은 배치와 표시항목 변경이므로 기본값과 계산 기준은 수정하지 않았다.
|
||||||
|
|
||||||
|
## 수정 파일과 이유
|
||||||
|
|
||||||
|
- `app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx`: 검색 JSX를 시도/시군구/경과년도 순으로 이동했다. 입력 이벤트, 조회조건, 초기화 및 페이지 처리 함수는 유지했다. 기존 `v1/fcltPpl/mngInsts` API를 한 번 조회해 목록 표시용 기관명을 연결한다. 기관 목록 수신 후 표시만 갱신하며 관로 목록을 다시 요청하지 않는다.
|
||||||
|
- `app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts`: 헤더에 `mngInstNm`을 추가하고 설치일자 뒤로 경과년도를 옮겼다. 9개 컬럼 폭 합계를 100%로 조정했다. 원본 기관명 우선, 코드에 맞는 `lwrInstNm`, 원본 코드, `-` 순으로 표시한다. 행 식별용 관리기관 코드는 변경하지 않는다.
|
||||||
|
- `scripts/aged-pipe-list.test.cjs`: 컬럼 순서/폭, 기관명 표시/대체값, 검색조건 순서 회귀 테스트 3개를 추가했다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
조회조건: 시도 → 시군구 → 경과년도
|
||||||
|
목록: 순번 → 시설관리번호 → 관리기관 → 설치일자 → 경과년도
|
||||||
|
→ 관경(mm) → 연장(m) → 용도 → 재질
|
||||||
|
```
|
||||||
|
|
||||||
|
## 영향 범위
|
||||||
|
|
||||||
|
- SN 노후관로 목록 팝업만 변경했다. 다른 앱과 공유 컴포넌트, 인증, 환경, 의존성, 배포 설정은 수정하지 않았다.
|
||||||
|
- 기존 조회 API를 재사용하며 백엔드와 DB 변경은 없다. 관리기관 조회 실패 시 안내 후 원본 코드를 표시한다.
|
||||||
|
- 엑셀은 기존 전체 속성 다운로드를 유지한다. 지도 행 선택의 식별값과 경과년도 계산도 유지한다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- `node --test scripts/aged-pipe-list.test.cjs`: 3개 통과.
|
||||||
|
- `pnpm exec tsc --noEmit --incremental false --pretty false`: 종료 코드 0, 오류 없음. 개발 서버가 실행 중이므로 전체 빌드는 수행하지 않았다.
|
||||||
|
- `git diff --check`: 통과(기존 Git 설정의 LF/CRLF 안내만 출력).
|
||||||
|
- 실제 로컬 브라우저 `http://localhost:3002/popup/agedPipePopup`에서 확인:
|
||||||
|
- 검색조건 시도/시군구/경과년도, 9개 목록 컬럼 순서 정상.
|
||||||
|
- 서울특별시/대전광역시 관리기관명, 설치일자, 경과년도 실데이터 표시 확인.
|
||||||
|
- 대전광역시/40년 조회 및 시군구 옵션 연계 확인.
|
||||||
|
- 유성구 선택 후 조회된 데이터 없음 상태 확인.
|
||||||
|
- 초기화 시 전체/전체/30년 복귀 및 목록 재조회 확인.
|
||||||
|
- 2페이지 이동 시 순번 11~20 및 관리기관명 유지 확인.
|
||||||
|
- 사용자 개발 서버 PID 43172(3002)는 종료·재시작하지 않았다. 별도 서버 및 임시 파일을 생성하지 않았다. 원시 캡처/로그는 파일로 저장하지 않았다. 정식 회귀 테스트와 이 결과 문서는 유지한다.
|
||||||
|
|
||||||
|
## 롤백 및 남은 사항
|
||||||
|
|
||||||
|
- 두 구현 파일에서 이번 diff만 역적용하면 이전 표시로 복구할 수 있다. 후속 변경이 있으면 파일 전체를 되돌리지 않는다.
|
||||||
|
- 실데이터 중 설치일자 `19000000`이 경과년도 126으로 표시되는 기존 날짜 검증 문제가 관찰됐다. 이번 배치 변경에서는 원본 데이터와 날짜 판정 규칙을 임의 수정하지 않았다.
|
||||||
|
- 엑셀 파일 생성 및 부모 지도창으로 위치 이동은 이번 변경에서 재검증하지 않았다.
|
||||||
|
|
||||||
|
## 후속 변경: 목록 팝업 중앙 배치
|
||||||
|
|
||||||
|
- 사용자 요청에 따라 `src/features/windowPopup/model/popupDefinitions.ts`의 `agedPipePopup`에서 `placement`, `alignTop`, `offsetLeft`, `offsetTop` 예외를 제거했다. 공통 열기 함수의 중앙 배치 기본값을 적용한다. 크기 1160×820과 다른 팝업 설정은 유지했다.
|
||||||
|
- 기존 화면설계서 슬라이드 8의 목록 기능을 유지하며, 창 위치는 이번 명시 요청을 적용했다.
|
||||||
|
- `scripts/aged-pipe-list.test.cjs`에 중앙 배치/크기 유지 검증을 추가했다. 전체 4개 테스트 및 `git diff --check` 통과.
|
||||||
|
- 로컬 브라우저에서 로그인 및 지도 진입을 확인했으나, 확인 시점의 지도에는 레이어 목록보기 버튼이 노출되지 않아 실제 새 창 위치는 미검증이다. 코드 기본값과 설정에 대한 자동 검증으로 구분해 기록한다.
|
||||||
|
- 이미 열린 이름 지정 팝업은 브라우저가 위치를 유지할 수 있으므로 창을 닫고 다시 열어 확인한다. 배포 및 사용자 서버 재시작은 수행하지 않았다.
|
||||||
|
- 롤백은 해당 팝업의 이전 위치 설정 네 항목만 복구한다. 임시 파일 생성·삭제 없음.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 하수관망도 레이어 전체 켜기·끄기
|
||||||
|
|
||||||
|
> 2026-09-23 후속 정리: 사용자 요청에 따라 `scripts/layer-bulk-selection.test.cjs`를 삭제했다. 아래 테스트 명령과 결과는 삭제 전 실행 이력이며 테스트 소스 보존 문구는 더 이상 적용하지 않는다. 구현 코드와 이 최종 보고서는 유지한다.
|
||||||
|
|
||||||
|
2026-09-23 사용자 요청으로 트리의 '레이어 관리' 제목 오른쪽에 전체 표시 토글을 추가했다.
|
||||||
|
후속 요청에 따라 두 버튼을 단일 스위치로 바꿨다. 모두 선택이면 ON, 일부 선택이면 OFF 위치와 '일부 켜짐' 문구로 표시한다.
|
||||||
|
OFF 또는 부분 선택 상태에서 누르면 전체 켜기, ON 상태에서 누르면 전체 끄기가 실행된다.
|
||||||
|
화면설계서의 하수관망도 및 GIS 관리 영역(SN_MN010302, 슬라이드 35)을 유지하고 이번 명시 요청을 기능 추가 근거로 적용한다.
|
||||||
|
|
||||||
|
## 변경
|
||||||
|
|
||||||
|
- SN map/page.tsx: showBulkLayerToggle=true 전달.
|
||||||
|
- business_components Gis.js, GisTree.js: 선택적 옵션 전달, 헤더 버튼, 전체 체크 이벤트.
|
||||||
|
- layerBulkSelection.js: exprsYn=Y 노드의 전체 선택을 계산하는 순수 함수.
|
||||||
|
- apps/sn/scripts/layer-bulk-selection.test.cjs: 정식 회귀 테스트 3개(전체·부분·꺼짐·빈 목록 상태 포함).
|
||||||
|
|
||||||
|
전체 켜기는 표시 허용 노드만 체크하고 숨김 노드는 켜지 않는다. 전체 끄기는 현재 트리 노드의 체크·표시를 해제한다.
|
||||||
|
gisCore.setLayerOnMap에 트리 ID만 전달하므로 배경지도나 별도 편집 오버레이를 일괄 끄는 API는 호출하지 않는다.
|
||||||
|
전체 이벤트에서 자동재생 타이머를 중지하고 재생 상태를 false로 변경한 뒤 체크 상태·지도 제어·업무 콜백을 한 번에 반영한다.
|
||||||
|
기존 개별 체크·트리 펼침·레이어 설정·클릭 동작은 유지한다.
|
||||||
|
|
||||||
|
## 다른 서비스 영향
|
||||||
|
|
||||||
|
Gis는 FO·WR·DM 및 SN의 다른 지도에서도 사용한다. showBulkLayerToggle 기본값은 false이고 SN 주 지도만 활성화했다.
|
||||||
|
공통 패키지의 허용 범위 안에서 수정했으며 다른 앱 코드·서버·DB·API 계약은 변경하지 않았다.
|
||||||
|
앞선 SN 순번 정순 변경은 별도 작업이며 유지했다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- TypeScript 검사 통과.
|
||||||
|
- 단위 테스트 3개: 표시 허용 부모·자식 선택, 숨김 제외, 전체 해제, 빈 목록 및 입력 비변경, 토글 상태 확인.
|
||||||
|
- 단일 토글 브라우저 재검증: 부분 선택 → ON(모두 체크) → OFF(모두 해제), 250px 헤더 배치 확인. 테스트용 지도 stub을 사용했으며 실데이터 네트워크 검증과 구분한다.
|
||||||
|
- 실제 GisTree와 GisContext에 지도 제어 stub 및 테스트 트리를 연결한 브라우저 검증:
|
||||||
|
- 초기 관로만 선택 → 전체 켜기 시 부모·관로·맨홀 체크 및 지도 제어 상태 true.
|
||||||
|
- 숨김 노드는 false 유지.
|
||||||
|
- 전체 끄기 시 모든 체크·제어 상태 false.
|
||||||
|
- 전체 끄기 후 관로 개별 체크 정상.
|
||||||
|
- 옵션 false일 때 버튼 미표시 및 기존 체크 유지.
|
||||||
|
- 250px 패널에서 제목 오른쪽 버튼 배치 확인.
|
||||||
|
- 테스트 데이터 기반 검증이며 실제 GeoServer·업무별 비동기 레이어 로딩 전체를 검증한 것은 아니다. 앞선 실데이터 검증에서 로컬 백엔드 8883 연결 거부가 확인되어 환경설정은 변경하지 않았다.
|
||||||
|
- 기존 개별 체크 경로는 상태 갱신 함수 안에서 업무 콜백을 호출하는 구조이므로 개발 모드 경고가 보일 수 있다. 이번 전체 체크 경로는 해당 방식으로 구현하지 않았다.
|
||||||
|
|
||||||
|
## 정리·복구
|
||||||
|
|
||||||
|
임시 검증 페이지 2개는 삭제하고 직접 기동한 개발서버는 종료한다. 캡처·원시 로그 파일은 생성하지 않았다.
|
||||||
|
토글 후속 검증에서는 개발서버 기동이 포트 사용 중으로 실패한 상태였으나 정리 시 기존 3002 프로세스를 종료하는 실수가 있었다.
|
||||||
|
이를 사용자에게 알리고 복원 기동을 시도했으며, 이미 다른 실행에서 3002가 재기동되어 중복 기동은 실패했다.
|
||||||
|
최종 확인 시 3002는 PID 47412(Next 명령 부모 PID 48580)로 LISTENING 상태다. 이 프로세스는 추가 종료하지 않았다.
|
||||||
|
최종 임시 검증 파일 삭제 후 TypeScript 검사 통과를 확인했다.
|
||||||
|
정식 테스트와 본 보고서는 보존한다. 커밋·push·배포는 수행하지 않았다.
|
||||||
|
복구하려면 SN의 showBulkLayerToggle 전달을 제거해 버튼 노출부터 해제할 수 있다. 전체 복구 시 이번 공통 GIS 변경만 역적용한다.
|
||||||
@@ -148,3 +148,21 @@
|
|||||||
- 목록은 CMS 코드값 또는 코드명과 정확히 일치하는 값만 코드명으로 표시하고 미등록 기존 값은 그대로 표시한다.
|
- 목록은 CMS 코드값 또는 코드명과 정확히 일치하는 값만 코드명으로 표시하고 미등록 기존 값은 그대로 표시한다.
|
||||||
- 영문 `lwpoCd` 저장 전환과 기존 데이터 이관은 이번 범위에서 수행하지 않았다.
|
- 영문 `lwpoCd` 저장 전환과 기존 데이터 이관은 이번 범위에서 수행하지 않았다.
|
||||||
- 백엔드 API, Excel 입출력과 DB는 변경하지 않았다.
|
- 백엔드 API, Excel 입출력과 DB는 변경하지 않았다.
|
||||||
|
|
||||||
|
## 2026-09-22 목록 사업코드·추진현황 검색조건 추가
|
||||||
|
|
||||||
|
- 사용자 유지보수 요청에 따라 목록 검색조건에 사업코드와 추진현황을 추가했다.
|
||||||
|
- 검색조건은 `사업구분 → 시도 → 시군구 → 사업코드 → 추진현황 → 사업명` 순서로 배치했다.
|
||||||
|
- 사업코드는 자유 입력으로 부분일치 조회하고, 추진현황은 기존 자유문자를 직접 입력하면서 `BIZPRGRSSTTS` 공통코드 후보를 datalist로 선택할 수 있게 했다.
|
||||||
|
- 두 조건을 목록 검색 상태에 포함해 조회 URL, 페이지 이동, 정렬, 상세·등록 후 목록 복귀와 초기화에서 동일하게 유지한다.
|
||||||
|
- Excel 다운로드도 적용된 검색 상태를 재사용하므로 현재 화면과 같은 사업코드·추진현황 조건의 전체 결과를 내려받는다.
|
||||||
|
- SN 운영 빌드와 별도 TypeScript 검사를 통과했다. 최초 빌드는 Windows 파일 핸들 한도 오류로 중단됐으나 단독 재시도에서 성공했다.
|
||||||
|
|
||||||
|
## 2026-09-22 공단의견 목록·폼·Excel 제거
|
||||||
|
|
||||||
|
- 사용자 유지보수 요청에 따라 목록의 공단의견 열과 등록·상세·수정 화면의 공단 의견 입력란을 제거했다.
|
||||||
|
- FE 저장 요청 타입에서 `pbcpOpnnCn`을 제거해 등록·수정 API 요청에 공단의견을 포함하지 않는다.
|
||||||
|
- 업로드 헤더 검증과 행 파싱에서 V열 공단의견을 제거하고 B~U의 20개 업무 열만 저장 요청으로 변환한다.
|
||||||
|
- 기존 B~V 업로드 파일의 V열이 남아 있어도 이를 읽거나 저장하지 않으며, 나머지 B~U 열은 같은 위치로 처리한다.
|
||||||
|
- FE Excel 다운로드의 V열 공단의견, 열 폭, 스타일 범위를 제거하고 B~U 양식으로 생성한다.
|
||||||
|
- DB 컬럼과 기존 값은 유지하며 화면·API·Excel에서만 제외한다.
|
||||||
|
|||||||
@@ -8,9 +8,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start:sn": "next start -p 3002",
|
"start:sn": "next start -p 3002",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"test:refactor": "node scripts/run-refactor-tests.cjs",
|
|
||||||
"typecheck": "tsc --noEmit --incremental false --pretty false",
|
"typecheck": "tsc --noEmit --incremental false --pretty false",
|
||||||
"check:boundaries": "node scripts/check-module-boundaries.cjs",
|
|
||||||
"lint:refactor": "eslint src/shared/api/http-transport.ts src/app/providers/app-provider.tsx src/features/dredging src/features/mapLayers src/features/gisManagement --rule \"@typescript-eslint/no-explicit-any:warn\" --max-warnings 22"
|
"lint:refactor": "eslint src/shared/api/http-transport.ts src/app/providers/app-provider.tsx src/features/dredging src/features/mapLayers src/features/gisManagement --rule \"@typescript-eslint/no-explicit-any:warn\" --max-warnings 22"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { AlertDialog } from "@/shared/ui/alert-dialog";
|
|||||||
import { PromptDialog } from "@/shared/ui/prompt-dialog";
|
import { PromptDialog } from "@/shared/ui/prompt-dialog";
|
||||||
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
|
||||||
import { BusinessUserMessageBridge } from "@/shared/ui/business-user-message-bridge";
|
import { BusinessUserMessageBridge } from "@/shared/ui/business-user-message-bridge";
|
||||||
|
import { GridCellTooltip } from "@/shared/ui/grid-cell-tooltip";
|
||||||
|
|
||||||
// 개발 도구는 실제 로컬 개발 환경에서만 별도 청크로 불러와 최초 페이지 컴파일 범위를 줄인다.
|
// 개발 도구는 실제 로컬 개발 환경에서만 별도 청크로 불러와 최초 페이지 컴파일 범위를 줄인다.
|
||||||
const LazyReactQueryDevtools = dynamic(
|
const LazyReactQueryDevtools = dynamic(
|
||||||
@@ -30,6 +31,7 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<StoreProvider>
|
<StoreProvider>
|
||||||
<BusinessUserMessageBridge />
|
<BusinessUserMessageBridge />
|
||||||
|
<GridCellTooltip />
|
||||||
<PromptProvider>
|
<PromptProvider>
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
<AuthWrapper>
|
<AuthWrapper>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/* SN 목록 그리드의 데이터 텍스트만 한 줄로 제한한다. 폼·병합·액션 셀은 보존한다. */
|
||||||
|
body[data-sn-grid-ellipsis="true"] table:has(> thead) > tbody > tr > td:not([colspan]):not([rowspan]):not([data-sn-cell-wrap]):not(:has(input, textarea, select, button, [role="checkbox"], [role="switch"], [contenteditable="true"], table, img, svg)) {
|
||||||
|
max-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 셀 안의 block/whitespace-normal·pre-line 텍스트도 실제 열 너비 안에서 생략한다. */
|
||||||
|
body[data-sn-grid-ellipsis="true"] table:has(> thead) > tbody > tr > td:not([colspan]):not([rowspan]):not([data-sn-cell-wrap]):not(:has(input, textarea, select, button, [role="checkbox"], [role="switch"], [contenteditable="true"], table, img, svg)) :is(div, span, a, p) {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
word-break: normal;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { getSnRowNumber } from "@/shared/lib/row-number";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||||
@@ -10,7 +11,6 @@ import useOnsemiroAddrStore from "@/app/store/useOnsemiroAddrStore";
|
|||||||
import {
|
import {
|
||||||
StandardDataTable,
|
StandardDataTable,
|
||||||
StandardPopupLayout,
|
StandardPopupLayout,
|
||||||
getStandardRowNumber,
|
|
||||||
} from "@common/business_components";
|
} from "@common/business_components";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -273,7 +273,7 @@ export default function CfmtnBgtPopupContent({
|
|||||||
() =>
|
() =>
|
||||||
(Array.isArray(data.content) ? data.content : []).map((row, index) => ({
|
(Array.isArray(data.content) ? data.content : []).map((row, index) => ({
|
||||||
...row,
|
...row,
|
||||||
rowNumber: getStandardRowNumber({
|
rowNumber: getSnRowNumber({
|
||||||
total: data.totalElements,
|
total: data.totalElements,
|
||||||
rowIndex: index,
|
rowIndex: index,
|
||||||
pageIndex: page,
|
pageIndex: page,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { getSnRowNumber } from "@/shared/lib/row-number";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
StandardDataTable,
|
StandardDataTable,
|
||||||
StandardTableSortHeader,
|
StandardTableSortHeader,
|
||||||
getStandardRowNumber,
|
|
||||||
type UiTableSort,
|
type UiTableSort,
|
||||||
} from "@common/business_components";
|
} from "@common/business_components";
|
||||||
import {
|
import {
|
||||||
@@ -463,7 +463,7 @@ export function CtyFldngPrvtnBizListPage() {
|
|||||||
result.content.map((row, i) =>
|
result.content.map((row, i) =>
|
||||||
flattenRow(
|
flattenRow(
|
||||||
row,
|
row,
|
||||||
getStandardRowNumber({
|
getSnRowNumber({
|
||||||
total: result.totalElements,
|
total: result.totalElements,
|
||||||
rowIndex: i,
|
rowIndex: i,
|
||||||
pageIndex: target - 1,
|
pageIndex: target - 1,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { getSnRowNumber } from "@/shared/lib/row-number";
|
||||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||||
import { fileDownload } from "@/components/lib/utils";
|
import { fileDownload } from "@/components/lib/utils";
|
||||||
import { getStartYearArray } from "@/components/ui/YearRangeSelectBox";
|
import { getStartYearArray } from "@/components/ui/YearRangeSelectBox";
|
||||||
@@ -20,7 +21,6 @@ import {
|
|||||||
STANDARD_ROW_NUMBER_HEADER,
|
STANDARD_ROW_NUMBER_HEADER,
|
||||||
StandardDataTable,
|
StandardDataTable,
|
||||||
StandardTableSortHeader,
|
StandardTableSortHeader,
|
||||||
getStandardRowNumber,
|
|
||||||
type UiTableSort,
|
type UiTableSort,
|
||||||
} from "@common/business_components";
|
} from "@common/business_components";
|
||||||
import {
|
import {
|
||||||
@@ -146,7 +146,7 @@ export function ExmnRegistrationPage() {
|
|||||||
() =>
|
() =>
|
||||||
businesses.map((business, index) => ({
|
businesses.map((business, index) => ({
|
||||||
...business,
|
...business,
|
||||||
rowNumber: getStandardRowNumber({
|
rowNumber: getSnRowNumber({
|
||||||
total: totalElements,
|
total: totalElements,
|
||||||
rowIndex: index,
|
rowIndex: index,
|
||||||
pageIndex: pagination.pageIndex,
|
pageIndex: pagination.pageIndex,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { getSnRowNumber } from "@/shared/lib/row-number";
|
||||||
import { useMemo, type ReactNode } from "react";
|
import { useMemo, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -9,7 +10,6 @@ import {
|
|||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
StandardDataTable,
|
StandardDataTable,
|
||||||
getStandardRowNumber,
|
|
||||||
type ScrollMode,
|
type ScrollMode,
|
||||||
type UiHeadProps,
|
type UiHeadProps,
|
||||||
} from "@common/business_components";
|
} from "@common/business_components";
|
||||||
@@ -43,7 +43,7 @@ const formatValue = (value: unknown) => {
|
|||||||
: String(value);
|
: String(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 조사관리 표를 CS 목록 규격과 역순 순번 계약으로 연결한다. */
|
/** 조사관리 표를 CS 목록 규격과 페이지 연속 오름차순 순번으로 연결한다. */
|
||||||
export function ExmnStandardDataTable<T extends Record<string, unknown>>({
|
export function ExmnStandardDataTable<T extends Record<string, unknown>>({
|
||||||
rows,
|
rows,
|
||||||
headlist,
|
headlist,
|
||||||
@@ -69,7 +69,7 @@ export function ExmnStandardDataTable<T extends Record<string, unknown>>({
|
|||||||
header: ROW_NUMBER_IDS.has(head.id) ? "순번" : head.title,
|
header: ROW_NUMBER_IDS.has(head.id) ? "순번" : head.title,
|
||||||
cell: ({ row, getValue }) => {
|
cell: ({ row, getValue }) => {
|
||||||
const value = ROW_NUMBER_IDS.has(head.id)
|
const value = ROW_NUMBER_IDS.has(head.id)
|
||||||
? getStandardRowNumber({
|
? getSnRowNumber({
|
||||||
total,
|
total,
|
||||||
rowIndex: row.index,
|
rowIndex: row.index,
|
||||||
pageIndex: page - 1,
|
pageIndex: page - 1,
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export function ExmnStatusDetailPanel({
|
|||||||
const counts = getCounts(row);
|
const counts = getCounts(row);
|
||||||
return {
|
return {
|
||||||
trtdstId: row.trtdstId,
|
trtdstId: row.trtdstId,
|
||||||
rowNumber: summaryRows.length - index,
|
rowNumber: index + 1,
|
||||||
trtdstNm: row.trtdstNm,
|
trtdstNm: row.trtdstNm,
|
||||||
trgtPrlgText: formatNumber(row.trgtPrlg),
|
trgtPrlgText: formatNumber(row.trgtPrlg),
|
||||||
drvngDstncText: formatNumber(row.drvngDstnc),
|
drvngDstncText: formatNumber(row.drvngDstnc),
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { getSnRowNumber } from "@/shared/lib/row-number";
|
||||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||||
import useStore from "@/store/useStore";
|
import useStore from "@/store/useStore";
|
||||||
import {
|
import {
|
||||||
STANDARD_ROW_NUMBER_HEADER,
|
STANDARD_ROW_NUMBER_HEADER,
|
||||||
StandardDataTable,
|
StandardDataTable,
|
||||||
StandardTableSortHeader,
|
StandardTableSortHeader,
|
||||||
getStandardRowNumber,
|
|
||||||
sortStandardTableRows,
|
sortStandardTableRows,
|
||||||
type UiTableSort,
|
type UiTableSort,
|
||||||
} from "@common/business_components";
|
} from "@common/business_components";
|
||||||
@@ -73,16 +73,16 @@ const createLocalKey = () =>
|
|||||||
? `client:${crypto.randomUUID()}`
|
? `client:${crypto.randomUUID()}`
|
||||||
: `client:${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
: `client:${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
/** 화면의 현재 행 순서를 전체 건수에서 시작하는 공통 내림차순 순번으로 정규화한다. */
|
/** 화면의 현재 행 순서에 1부터 오름차순 표시 순번을 붙인다. */
|
||||||
const withDescendingRowNumbers = (rows: TrtdstTableRow[]): TrtdstTableRow[] =>
|
const withAscendingRowNumbers = (rows: TrtdstTableRow[]): TrtdstTableRow[] =>
|
||||||
rows.map((row, index) => ({
|
rows.map((row, index) => ({
|
||||||
...row,
|
...row,
|
||||||
rowNumber: getStandardRowNumber({ total: rows.length, rowIndex: index }),
|
rowNumber: getSnRowNumber({ total: rows.length, rowIndex: index }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/** 서버 처리분구 목록을 공통 표와 변경 추적에 필요한 행 구조로 변환한다. */
|
/** 서버 처리분구 목록을 공통 표와 변경 추적에 필요한 행 구조로 변환한다. */
|
||||||
const toTableRows = (items: TrtdstSettingDto[]): TrtdstTableRow[] =>
|
const toTableRows = (items: TrtdstSettingDto[]): TrtdstTableRow[] =>
|
||||||
withDescendingRowNumbers(
|
withAscendingRowNumbers(
|
||||||
items.map((item) => ({
|
items.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
trtdstId: String(item.trtdstId),
|
trtdstId: String(item.trtdstId),
|
||||||
@@ -103,7 +103,7 @@ const toSavedTableRows = (
|
|||||||
rows: TrtdstTableRow[],
|
rows: TrtdstTableRow[],
|
||||||
savedItems: TrtdstSettingDto[] = [],
|
savedItems: TrtdstSettingDto[] = [],
|
||||||
): TrtdstTableRow[] =>
|
): TrtdstTableRow[] =>
|
||||||
withDescendingRowNumbers(
|
withAscendingRowNumbers(
|
||||||
rows
|
rows
|
||||||
.filter((row) => row.rowStatus !== "deletePending")
|
.filter((row) => row.rowStatus !== "deletePending")
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
@@ -300,7 +300,7 @@ export function TrtdstManagementPage() {
|
|||||||
setSort(undefined);
|
setSort(undefined);
|
||||||
setPagination((current) => ({ ...current, pageIndex: 0 }));
|
setPagination((current) => ({ ...current, pageIndex: 0 }));
|
||||||
setRows((current) =>
|
setRows((current) =>
|
||||||
withDescendingRowNumbers([
|
withAscendingRowNumbers([
|
||||||
...current,
|
...current,
|
||||||
{
|
{
|
||||||
trtdstId: "",
|
trtdstId: "",
|
||||||
@@ -353,7 +353,7 @@ export function TrtdstManagementPage() {
|
|||||||
if (selectedRow.rowStatus === "added") {
|
if (selectedRow.rowStatus === "added") {
|
||||||
if (!(await confirm("선택한 처리분구를 삭제하시겠습니까?"))) return;
|
if (!(await confirm("선택한 처리분구를 삭제하시겠습니까?"))) return;
|
||||||
setRows((current) =>
|
setRows((current) =>
|
||||||
withDescendingRowNumbers(
|
withAscendingRowNumbers(
|
||||||
current.filter((row) => row.localKey !== selectedRow.localKey),
|
current.filter((row) => row.localKey !== selectedRow.localKey),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const SHEET_CONTRACTS: SheetContract[] = [
|
|||||||
|
|
||||||
const DATA_START_ROW_INDEX = 7;
|
const DATA_START_ROW_INDEX = 7;
|
||||||
const FIRST_DATA_COLUMN_INDEX = 1;
|
const FIRST_DATA_COLUMN_INDEX = 1;
|
||||||
const DATA_COLUMN_COUNT = 21;
|
const DATA_COLUMN_COUNT = 20;
|
||||||
const DOWNLOAD_FILENAME = "노후하수관로 정밀조사 및 정비사업 현황.xlsx";
|
const DOWNLOAD_FILENAME = "노후하수관로 정밀조사 및 정비사업 현황.xlsx";
|
||||||
|
|
||||||
/** API 문자열에 남은 Excel 개행 이스케이프를 실제 줄바꿈으로 정리한다. */
|
/** API 문자열에 남은 Excel 개행 이스케이프를 실제 줄바꿈으로 정리한다. */
|
||||||
@@ -63,7 +63,7 @@ const validateHeaders = (sheet: XLSX.WorkSheet, contract: SheetContract) => {
|
|||||||
[6, 14, "증빙서류제출여부"], [5, 15, contract.progressHeader],
|
[6, 14, "증빙서류제출여부"], [5, 15, contract.progressHeader],
|
||||||
[6, 15, contract.startDateHeader], [6, 16, "준공(예정)일자(yyyy.mm.dd)"],
|
[6, 15, contract.startDateHeader], [6, 16, "준공(예정)일자(yyyy.mm.dd)"],
|
||||||
[5, 17, "사업추진부진사유"], [5, 18, "사업담당자(지자체)"],
|
[5, 17, "사업추진부진사유"], [5, 18, "사업담당자(지자체)"],
|
||||||
[6, 18, "이름"], [6, 19, "연락처"], [5, 20, "비고"], [6, 21, "공단의견"],
|
[6, 18, "이름"], [6, 19, "연락처"], [5, 20, "비고"],
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [rowIndex, columnIndex, expected] of expectedHeaders) {
|
for (const [rowIndex, columnIndex, expected] of expectedHeaders) {
|
||||||
@@ -116,7 +116,7 @@ const dateText = (value: string): string | null => {
|
|||||||
return (digits || normalized).slice(0, 8);
|
return (digits || normalized).slice(0, 8);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** B~V가 모두 비어 있는 행은 데이터가 아닌 양식 여백으로 판단한다. */
|
/** B~U가 모두 비어 있는 행은 데이터가 아닌 양식 여백으로 판단한다. */
|
||||||
const isEmptyDataRow = (sheet: XLSX.WorkSheet, rowIndex: number) =>
|
const isEmptyDataRow = (sheet: XLSX.WorkSheet, rowIndex: number) =>
|
||||||
Array.from({ length: DATA_COLUMN_COUNT }, (_, offset) =>
|
Array.from({ length: DATA_COLUMN_COUNT }, (_, offset) =>
|
||||||
cellText(sheet, rowIndex, FIRST_DATA_COLUMN_INDEX + offset),
|
cellText(sheet, rowIndex, FIRST_DATA_COLUMN_INDEX + offset),
|
||||||
@@ -159,7 +159,6 @@ const parseSheet = (sheet: XLSX.WorkSheet, contract: SheetContract): ObsltPplBus
|
|||||||
bizPicNm: optionalText(valueAt(17)),
|
bizPicNm: optionalText(valueAt(17)),
|
||||||
bizPicCnpl: optionalText(valueAt(18)),
|
bizPicCnpl: optionalText(valueAt(18)),
|
||||||
rmrkCn: optionalText(valueAt(19)),
|
rmrkCn: optionalText(valueAt(19)),
|
||||||
pbcpOpnnCn: optionalText(valueAt(20)),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -183,27 +182,27 @@ export const parseObsltPplExcel = async (file: File): Promise<ObsltPplBusinessSa
|
|||||||
return parseObsltPplWorkbook(await file.arrayBuffer());
|
return parseObsltPplWorkbook(await file.arrayBuffer());
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 업로드 검증과 정확히 같은 B6:V7 위치에 시트별 헤더를 배치한다. */
|
/** 업로드 검증과 정확히 같은 B6:U7 위치에 시트별 헤더를 배치한다. */
|
||||||
const downloadHeaderRows = (contract: SheetContract) => {
|
const downloadHeaderRows = (contract: SheetContract) => {
|
||||||
const rows = Array.from({ length: DATA_START_ROW_INDEX }, () => Array<string | number>(22).fill(""));
|
const rows = Array.from({ length: DATA_START_ROW_INDEX }, () => Array<string | number>(21).fill(""));
|
||||||
const topHeaders = [
|
const topHeaders = [
|
||||||
"순번", "정밀\n조사\n차수", contract.businessCodeHeader.replace("사업코드", "\n사업코드"),
|
"순번", "정밀\n조사\n차수", contract.businessCodeHeader.replace("사업코드", "\n사업코드"),
|
||||||
"시도", "시군", "환경청", "사업명", "추진현황", "착수\n년도", "준공(예정)\n년도",
|
"시도", "시군", "환경청", "사업명", "추진현황", "착수\n년도", "준공(예정)\n년도",
|
||||||
contract.extensionHeader, "", "", "", contract.progressHeader.replace("추진현황", " 추진현황"),
|
contract.extensionHeader, "", "", "", contract.progressHeader.replace("추진현황", " 추진현황"),
|
||||||
"", "사업추진 부진 사유", "사업 담당자(지자체)", "", "비고", "",
|
"", "사업추진 부진 사유", "사업 담당자(지자체)", "", "비고",
|
||||||
];
|
];
|
||||||
const bottomHeaders = [
|
const bottomHeaders = [
|
||||||
"", "", "", "", "", "", "", "", "", "", "계획연장(km)", "상반기(예상)연장(km)",
|
"", "", "", "", "", "", "", "", "", "", "계획연장(km)", "상반기(예상)연장(km)",
|
||||||
"잔여연장(km)", "증빙서류\n제출여부", contract.startDateHeader.replace("(yyyy.mm.dd)", "\n(yyyy.mm.dd)"),
|
"잔여연장(km)", "증빙서류\n제출여부", contract.startDateHeader.replace("(yyyy.mm.dd)", "\n(yyyy.mm.dd)"),
|
||||||
"준공(예정)일자\n(yyyy.mm.dd)", "", "이름", "연락처",
|
"준공(예정)일자\n(yyyy.mm.dd)", "", "이름", "연락처",
|
||||||
"(필요시 사업 담당자 추가 작성)", "공단의견",
|
"(필요시 사업 담당자 추가 작성)",
|
||||||
];
|
];
|
||||||
topHeaders.forEach((value, index) => { rows[5]![index + FIRST_DATA_COLUMN_INDEX] = value; });
|
topHeaders.forEach((value, index) => { rows[5]![index + FIRST_DATA_COLUMN_INDEX] = value; });
|
||||||
bottomHeaders.forEach((value, index) => { rows[6]![index + FIRST_DATA_COLUMN_INDEX] = value; });
|
bottomHeaders.forEach((value, index) => { rows[6]![index + FIRST_DATA_COLUMN_INDEX] = value; });
|
||||||
return rows;
|
return rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** DB에 저장하지 않는 B열 순번을 시트 출력 순서로 만들고 C~V에 업무값을 배치한다. */
|
/** DB에 저장하지 않는 B열 순번을 시트 출력 순서로 만들고 C~U에 업무값을 배치한다. */
|
||||||
const downloadDataRow = (row: ObsltPplBusiness, index: number): Array<string | number> => [
|
const downloadDataRow = (row: ObsltPplBusiness, index: number): Array<string | number> => [
|
||||||
"",
|
"",
|
||||||
index + 1,
|
index + 1,
|
||||||
@@ -226,7 +225,6 @@ const downloadDataRow = (row: ObsltPplBusiness, index: number): Array<string | n
|
|||||||
excelTextValue(row.bizPicNm),
|
excelTextValue(row.bizPicNm),
|
||||||
excelTextValue(row.bizPicCnpl),
|
excelTextValue(row.bizPicCnpl),
|
||||||
excelTextValue(row.rmrkCn),
|
excelTextValue(row.rmrkCn),
|
||||||
excelTextValue(row.pbcpOpnnCn),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 조회 결과를 정밀조사·정비사업 두 시트의 재업로드 가능한 위치 구조로 생성한다. */
|
/** 조회 결과를 정밀조사·정비사업 두 시트의 재업로드 가능한 위치 구조로 생성한다. */
|
||||||
@@ -266,7 +264,7 @@ export const buildObsltPplWorkbook = (businesses: ObsltPplBusiness[]): XLSX.Work
|
|||||||
];
|
];
|
||||||
const widths = [
|
const widths = [
|
||||||
3, 7, 9, 16, 12, 12, 16, 34, 10, 9, 11, 13, 16, 13, 11, 16, 16,
|
3, 7, 9, 16, 12, 12, 16, 34, 10, 9, 11, 13, 16, 13, 11, 16, 16,
|
||||||
34, 11, 16, 34, 24,
|
34, 11, 16, 34,
|
||||||
];
|
];
|
||||||
worksheet["!cols"] = widths.map((wch) => ({ wch }));
|
worksheet["!cols"] = widths.map((wch) => ({ wch }));
|
||||||
XLSX.utils.book_append_sheet(workbook, worksheet, contract.sheetName);
|
XLSX.utils.book_append_sheet(workbook, worksheet, contract.sheetName);
|
||||||
@@ -304,7 +302,7 @@ export const downloadObsltPplExcel = async (
|
|||||||
fill: "FFF2CC",
|
fill: "FFF2CC",
|
||||||
border: true,
|
border: true,
|
||||||
});
|
});
|
||||||
styledSheet.range("B6:V7").style({
|
styledSheet.range("B6:U7").style({
|
||||||
bold: true,
|
bold: true,
|
||||||
fontFamily: "맑은 고딕",
|
fontFamily: "맑은 고딕",
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
@@ -315,7 +313,7 @@ export const downloadObsltPplExcel = async (
|
|||||||
});
|
});
|
||||||
styledSheet.range("B6:H7").style("fill", "B4C6E7");
|
styledSheet.range("B6:H7").style("fill", "B4C6E7");
|
||||||
styledSheet.range("I6:Q7").style("fill", "FFF2CC");
|
styledSheet.range("I6:Q7").style("fill", "FFF2CC");
|
||||||
styledSheet.range("R6:V7").style("fill", "FCE4D6");
|
styledSheet.range("R6:U7").style("fill", "FCE4D6");
|
||||||
styledSheet.row(2).height(26);
|
styledSheet.row(2).height(26);
|
||||||
styledSheet.row(3).height(26);
|
styledSheet.row(3).height(26);
|
||||||
styledSheet.row(5).height(22);
|
styledSheet.row(5).height(22);
|
||||||
@@ -325,7 +323,7 @@ export const downloadObsltPplExcel = async (
|
|||||||
if (rowCount > 0) {
|
if (rowCount > 0) {
|
||||||
const dataStartRow = DATA_START_ROW_INDEX + 1;
|
const dataStartRow = DATA_START_ROW_INDEX + 1;
|
||||||
const dataEndRow = DATA_START_ROW_INDEX + rowCount;
|
const dataEndRow = DATA_START_ROW_INDEX + rowCount;
|
||||||
styledSheet.range(`B${dataStartRow}:V${dataEndRow}`).style({
|
styledSheet.range(`B${dataStartRow}:U${dataEndRow}`).style({
|
||||||
fontFamily: "맑은 고딕",
|
fontFamily: "맑은 고딕",
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
horizontalAlignment: "center",
|
horizontalAlignment: "center",
|
||||||
@@ -336,7 +334,7 @@ export const downloadObsltPplExcel = async (
|
|||||||
styledSheet
|
styledSheet
|
||||||
.range(`L${dataStartRow}:N${dataEndRow}`)
|
.range(`L${dataStartRow}:N${dataEndRow}`)
|
||||||
.style("numberFormat", "#,##0.###");
|
.style("numberFormat", "#,##0.###");
|
||||||
["H", "R", "U", "V"].forEach((column) =>
|
["H", "R", "U"].forEach((column) =>
|
||||||
styledSheet
|
styledSheet
|
||||||
.range(`${column}${dataStartRow}:${column}${dataEndRow}`)
|
.range(`${column}${dataStartRow}:${column}${dataEndRow}`)
|
||||||
.style({ horizontalAlignment: "left", wrapText: true }),
|
.style({ horizontalAlignment: "left", wrapText: true }),
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export interface ObsltPplBusiness {
|
|||||||
bizPicNm?: string | null;
|
bizPicNm?: string | null;
|
||||||
bizPicCnpl?: string | null;
|
bizPicCnpl?: string | null;
|
||||||
rmrkCn?: string | null;
|
rmrkCn?: string | null;
|
||||||
pbcpOpnnCn?: string | null;
|
|
||||||
regDt?: string | null;
|
regDt?: string | null;
|
||||||
mdfcnDt?: string | null;
|
mdfcnDt?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ const FIELDS: Array<{
|
|||||||
{ key: "bizPicNm", label: "담당자 이름" },
|
{ key: "bizPicNm", label: "담당자 이름" },
|
||||||
{ key: "bizPicCnpl", label: "담당자 연락처" },
|
{ key: "bizPicCnpl", label: "담당자 연락처" },
|
||||||
{ key: "rmrkCn", label: "비고", type: "textarea" },
|
{ key: "rmrkCn", label: "비고", type: "textarea" },
|
||||||
{ key: "pbcpOpnnCn", label: "공단 의견", type: "textarea" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const NUMBER_KEYS = new Set<FormKey>([
|
const NUMBER_KEYS = new Set<FormKey>([
|
||||||
@@ -530,7 +529,6 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
|
|||||||
{renderField("bizPicCnpl", 2)}
|
{renderField("bizPicCnpl", 2)}
|
||||||
{renderField("bizPrplDlyRsnCn", 3)}
|
{renderField("bizPrplDlyRsnCn", 3)}
|
||||||
{renderField("rmrkCn", 3)}
|
{renderField("rmrkCn", 3)}
|
||||||
{renderField("pbcpOpnnCn", 3)}
|
|
||||||
</StandardFormSection>
|
</StandardFormSection>
|
||||||
|
|
||||||
<div className="flex justify-center gap-4 pb-8 pt-6">
|
<div className="flex justify-center gap-4 pb-8 pt-6">
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
*/
|
*/
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { getSnRowNumber } from "@/shared/lib/row-number";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
StandardDataTable,
|
StandardDataTable,
|
||||||
StandardTableSortHeader,
|
StandardTableSortHeader,
|
||||||
getStandardRowNumber,
|
|
||||||
type UiTableSort,
|
type UiTableSort,
|
||||||
} from "@common/business_components";
|
} from "@common/business_components";
|
||||||
import {
|
import {
|
||||||
@@ -65,8 +65,10 @@ type ObsltPplTableColumn = {
|
|||||||
};
|
};
|
||||||
type ObsltPplSearch = {
|
type ObsltPplSearch = {
|
||||||
bizSe: string;
|
bizSe: string;
|
||||||
|
bizCd: string;
|
||||||
ctpvNm: string;
|
ctpvNm: string;
|
||||||
sigunNm: string;
|
sigunNm: string;
|
||||||
|
prplPrst: string;
|
||||||
bizNm: string;
|
bizNm: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,7 +122,6 @@ const TABLE_COLUMNS: ObsltPplTableColumn[] = [
|
|||||||
{ id: "bizPicNm", title: "담당자 이름", width: 90 },
|
{ id: "bizPicNm", title: "담당자 이름", width: 90 },
|
||||||
{ id: "bizPicCnpl", title: "담당자 연락처", width: 120 },
|
{ id: "bizPicCnpl", title: "담당자 연락처", width: 120 },
|
||||||
{ id: "rmrkCn", title: "비고", width: 160, align: "left" },
|
{ id: "rmrkCn", title: "비고", width: 160, align: "left" },
|
||||||
{ id: "pbcpOpnnCn", title: "공단\n의견", width: 90, align: "left" },
|
|
||||||
];
|
];
|
||||||
const TABLE_MIN_WIDTH = "280rem";
|
const TABLE_MIN_WIDTH = "280rem";
|
||||||
|
|
||||||
@@ -154,6 +155,7 @@ export function ObsltPplListPage() {
|
|||||||
// 검색값·원본 행은 유지하고 사업구분·추진·증빙 표시명만 CMS 기준으로 변환한다.
|
// 검색값·원본 행은 유지하고 사업구분·추진·증빙 표시명만 CMS 기준으로 변환한다.
|
||||||
const {
|
const {
|
||||||
obsltBusinessTypeOptions,
|
obsltBusinessTypeOptions,
|
||||||
|
businessProgressOptions,
|
||||||
evidenceStatusLabels,
|
evidenceStatusLabels,
|
||||||
businessProgressLabels,
|
businessProgressLabels,
|
||||||
} = useOperBusinessCommonCodes();
|
} = useOperBusinessCommonCodes();
|
||||||
@@ -162,8 +164,10 @@ export function ObsltPplListPage() {
|
|||||||
const initialSearch = useMemo<ObsltPplSearch>(
|
const initialSearch = useMemo<ObsltPplSearch>(
|
||||||
() => ({
|
() => ({
|
||||||
bizSe: searchParams.get("bizSe") ?? "정밀조사",
|
bizSe: searchParams.get("bizSe") ?? "정밀조사",
|
||||||
|
bizCd: searchParams.get("bizCd") ?? "",
|
||||||
ctpvNm: searchParams.get("ctpvNm") ?? "",
|
ctpvNm: searchParams.get("ctpvNm") ?? "",
|
||||||
sigunNm: searchParams.get("sigunNm") ?? "",
|
sigunNm: searchParams.get("sigunNm") ?? "",
|
||||||
|
prplPrst: searchParams.get("prplPrst") ?? "",
|
||||||
bizNm: searchParams.get("bizNm") ?? "",
|
bizNm: searchParams.get("bizNm") ?? "",
|
||||||
}),
|
}),
|
||||||
[searchParams],
|
[searchParams],
|
||||||
@@ -331,7 +335,7 @@ export function ObsltPplListPage() {
|
|||||||
setRows(
|
setRows(
|
||||||
result.content.map((row, index) => ({
|
result.content.map((row, index) => ({
|
||||||
...formatBusinessForDisplay(row),
|
...formatBusinessForDisplay(row),
|
||||||
rowNumber: getStandardRowNumber({
|
rowNumber: getSnRowNumber({
|
||||||
total: result.totalElements,
|
total: result.totalElements,
|
||||||
rowIndex: index,
|
rowIndex: index,
|
||||||
pageIndex: targetPage - 1,
|
pageIndex: targetPage - 1,
|
||||||
@@ -454,8 +458,10 @@ export function ObsltPplListPage() {
|
|||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
const nextSearch: ObsltPplSearch = {
|
const nextSearch: ObsltPplSearch = {
|
||||||
bizSe: "정밀조사",
|
bizSe: "정밀조사",
|
||||||
|
bizCd: "",
|
||||||
ctpvNm: "",
|
ctpvNm: "",
|
||||||
sigunNm: "",
|
sigunNm: "",
|
||||||
|
prplPrst: "",
|
||||||
bizNm: "",
|
bizNm: "",
|
||||||
};
|
};
|
||||||
setSearch(nextSearch);
|
setSearch(nextSearch);
|
||||||
@@ -599,6 +605,54 @@ export function ObsltPplListPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="obslt-ppl-biz-code"
|
||||||
|
className="mr-2 whitespace-nowrap text-label"
|
||||||
|
>
|
||||||
|
사업코드
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="obslt-ppl-biz-code"
|
||||||
|
className="w-[14rem] rounded-sm !text-[1.3rem]"
|
||||||
|
value={search.bizCd}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSearch((current) => ({
|
||||||
|
...current,
|
||||||
|
bizCd: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="obslt-ppl-progress"
|
||||||
|
className="mr-2 whitespace-nowrap text-label"
|
||||||
|
>
|
||||||
|
추진현황
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="obslt-ppl-progress"
|
||||||
|
className="w-[15rem] rounded-sm !text-[1.3rem]"
|
||||||
|
list="obslt-ppl-progress-suggestions"
|
||||||
|
value={search.prplPrst}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSearch((current) => ({
|
||||||
|
...current,
|
||||||
|
prplPrst: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<datalist id="obslt-ppl-progress-suggestions">
|
||||||
|
{businessProgressOptions.map((option) => (
|
||||||
|
<option key={option.code} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label
|
<Label
|
||||||
htmlFor="obslt-ppl-biz-name"
|
htmlFor="obslt-ppl-biz-name"
|
||||||
|
|||||||
@@ -140,10 +140,7 @@ export const windowPopupDefinitions = {
|
|||||||
title: "노후 하수관로 목록",
|
title: "노후 하수관로 목록",
|
||||||
width: 1160,
|
width: 1160,
|
||||||
height: 820,
|
height: 820,
|
||||||
placement: "browser-left",
|
// 다른 목록 팝업과 동일한 공통 중앙 배치를 사용한다.
|
||||||
alignTop: true,
|
|
||||||
offsetLeft: 0,
|
|
||||||
offsetTop: 70,
|
|
||||||
},
|
},
|
||||||
sinkholePopup: {
|
sinkholePopup: {
|
||||||
key: "sinkholePopup",
|
key: "sinkholePopup",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { getSnRowNumber } from "@/shared/lib/row-number";
|
||||||
import { type ReactNode, useMemo } from "react";
|
import { type ReactNode, useMemo } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -10,7 +11,6 @@ import {
|
|||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getStandardRowNumber,
|
|
||||||
type ScrollMode,
|
type ScrollMode,
|
||||||
StandardDataTable,
|
StandardDataTable,
|
||||||
StandardTableSortHeader,
|
StandardTableSortHeader,
|
||||||
@@ -109,7 +109,7 @@ export default function MapPopupDataTable<T extends Record<string, unknown>>({
|
|||||||
const rowIndex = row.index;
|
const rowIndex = row.index;
|
||||||
const rendered = renderCell?.(row.original, head.id, rowIndex);
|
const rendered = renderCell?.(row.original, head.id, rowIndex);
|
||||||
const value = ROW_NUMBER_IDS.has(head.id)
|
const value = ROW_NUMBER_IDS.has(head.id)
|
||||||
? getStandardRowNumber({
|
? getSnRowNumber({
|
||||||
total,
|
total,
|
||||||
rowIndex,
|
rowIndex,
|
||||||
pageIndex: page - 1,
|
pageIndex: page - 1,
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/** SN 표시 순번 계산용 인자. pageIndex와 rowIndex는 0부터 시작한다. */
|
||||||
|
type SnRowNumberParams = {
|
||||||
|
/** 기존 호출부의 전체 건수는 페이지 계산에 사용하지 않는다. */
|
||||||
|
total?: number;
|
||||||
|
rowIndex: number;
|
||||||
|
pageIndex?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 데이터 정렬·식별자는 유지하고 현재 조회 순서에 1부터 연속 순번을 붙인다. */
|
||||||
|
export function getSnRowNumber({
|
||||||
|
rowIndex,
|
||||||
|
pageIndex = 0,
|
||||||
|
pageSize = 0,
|
||||||
|
}: SnRowNumberParams): number {
|
||||||
|
return pageIndex * pageSize + rowIndex + 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/** CSS 말줄임표 대상과 같은 범위를 사용하며 입력/액션/병합 셀의 툴팁은 건드리지 않는다. */
|
||||||
|
const CELL_SELECTOR = 'body[data-sn-grid-ellipsis="true"] table:has(> thead) > tbody > tr > td:not([colspan]):not([rowspan]):not([data-sn-cell-wrap]):not(:has(input, textarea, select, button, [role="checkbox"], [role="switch"], [contenteditable="true"], table, img, svg))';
|
||||||
|
|
||||||
|
/** 동적 목록·포털에도 공통 적용하며 기존 title과 실제 데이터는 변경하지 않는다. */
|
||||||
|
export function GridCellTooltip() {
|
||||||
|
useEffect(() => {
|
||||||
|
const generatedTitles = new WeakMap<HTMLTableCellElement, string>();
|
||||||
|
const showFullText = (event: Event) => {
|
||||||
|
if (!(event.target instanceof Element)) return;
|
||||||
|
const cell = event.target.closest<HTMLTableCellElement>(CELL_SELECTOR);
|
||||||
|
if (!cell) return;
|
||||||
|
const previous = generatedTitles.get(cell);
|
||||||
|
// 화면에서 이미 지정한 상세 툴팁을 우선한다.
|
||||||
|
if ((cell.hasAttribute("title") && cell.title !== previous) || cell.querySelector("[title]")) return;
|
||||||
|
const overflowed = [cell, ...cell.querySelectorAll<HTMLElement>("div, span, a, p")]
|
||||||
|
.some((element) => element.scrollWidth > element.clientWidth + 1);
|
||||||
|
if (overflowed) {
|
||||||
|
const fullText = cell.textContent?.trim() ?? "";
|
||||||
|
cell.title = fullText;
|
||||||
|
generatedTitles.set(cell, fullText);
|
||||||
|
} else if (previous !== undefined) {
|
||||||
|
cell.removeAttribute("title");
|
||||||
|
generatedTitles.delete(cell);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerover", showFullText);
|
||||||
|
document.addEventListener("focusin", showFullText);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerover", showFullText);
|
||||||
|
document.removeEventListener("focusin", showFullText);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -93,6 +93,8 @@ function Gis({
|
|||||||
showLayerPanelTitle = true,
|
showLayerPanelTitle = true,
|
||||||
// sn 관망도처럼 트리와 선택 설정을 카드 두 개로 구분해야 하는 화면에서만 활성화한다.
|
// sn 관망도처럼 트리와 선택 설정을 카드 두 개로 구분해야 하는 화면에서만 활성화한다.
|
||||||
sectionedTreePanel = false,
|
sectionedTreePanel = false,
|
||||||
|
// 전체 체크 조작은 필요한 화면에서만 명시적으로 활성화한다.
|
||||||
|
showBulkLayerToggle = false,
|
||||||
showZoomScaleInfo = false,
|
showZoomScaleInfo = false,
|
||||||
}) {
|
}) {
|
||||||
const mapRef = useRef();
|
const mapRef = useRef();
|
||||||
@@ -345,6 +347,7 @@ function Gis({
|
|||||||
detachedToggleButton={detachedTreeToggleButton}
|
detachedToggleButton={detachedTreeToggleButton}
|
||||||
showLayerPanelTitle={showLayerPanelTitle}
|
showLayerPanelTitle={showLayerPanelTitle}
|
||||||
sectionedPanel={sectionedTreePanel}
|
sectionedPanel={sectionedTreePanel}
|
||||||
|
showBulkLayerToggle={showBulkLayerToggle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{typeof WaterDepth === "function" && (
|
{typeof WaterDepth === "function" && (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import React, { useEffect, useLayoutEffect, useState, useRef, useCallback } from
|
|||||||
import { showBusinessUserMessage } from "../../lib/user-message";
|
import { showBusinessUserMessage } from "../../lib/user-message";
|
||||||
import { useGisContext } from "./Gis";
|
import { useGisContext } from "./Gis";
|
||||||
import GisApp from "./lib/gis.core";
|
import GisApp from "./lib/gis.core";
|
||||||
|
import { getBulkLayerSelection, getBulkLayerState } from "./layerBulkSelection";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GisTree (React memoized component)
|
* GisTree (React memoized component)
|
||||||
@@ -63,6 +64,7 @@ export default React.memo(function GisTree({
|
|||||||
showLayerPanelTitle = true,
|
showLayerPanelTitle = true,
|
||||||
detachedToggleButton = false,
|
detachedToggleButton = false,
|
||||||
sectionedPanel = false,
|
sectionedPanel = false,
|
||||||
|
showBulkLayerToggle = false,
|
||||||
}) {
|
}) {
|
||||||
const { gisCore, treeData } = useGisContext();
|
const { gisCore, treeData } = useGisContext();
|
||||||
|
|
||||||
@@ -71,6 +73,8 @@ export default React.memo(function GisTree({
|
|||||||
const [expanded, setExpanded] = useState([]);
|
const [expanded, setExpanded] = useState([]);
|
||||||
const [nodes, setNodes] = useState([]);
|
const [nodes, setNodes] = useState([]);
|
||||||
const [activeTab, setActiveTab] = useState("layers");
|
const [activeTab, setActiveTab] = useState("layers");
|
||||||
|
// 전체 선택이면 ON, 부분 선택이면 OFF 위치와 별도 상태 문구로 구분한다.
|
||||||
|
const bulkLayerState = getBulkLayerState(nodes, checked);
|
||||||
|
|
||||||
// 자동 애니메이션 관련 ref (실행 인덱스, 대상 노드 목록, 일시정지, 타이머)
|
// 자동 애니메이션 관련 ref (실행 인덱스, 대상 노드 목록, 일시정지, 타이머)
|
||||||
const currentAnimationIndexRef = useRef(0);
|
const currentAnimationIndexRef = useRef(0);
|
||||||
@@ -470,6 +474,19 @@ export default React.memo(function GisTree({
|
|||||||
isPausedRef.current = true;
|
isPausedRef.current = true;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/** 전체 조작은 자동재생을 멈추고 트리·지도·업무 콜백에 같은 선택을 한 번 반영한다. */
|
||||||
|
const handleBulkLayerToggle = useCallback((enabled) => {
|
||||||
|
if (!gisCore || nodes.length === 0) return;
|
||||||
|
pauseAnimation();
|
||||||
|
setIsPlaying(false);
|
||||||
|
const { checkedIds, uncheckedIds, selectedNodes } = getBulkLayerSelection(nodes, enabled);
|
||||||
|
// 배경지도나 편집 오버레이가 아닌 현재 트리에 등록된 레이어만 변경한다.
|
||||||
|
gisCore.setLayerOnMap(uncheckedIds, false);
|
||||||
|
gisCore.setLayerOnMap(checkedIds, true);
|
||||||
|
setChecked(checkedIds);
|
||||||
|
onCheckedChange?.(checkedIds, selectedNodes);
|
||||||
|
}, [gisCore, nodes, onCheckedChange, pauseAnimation, setIsPlaying]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 재생/정지/대상/간격 변경에 반응하여 애니메이션을 관리합니다.
|
* 재생/정지/대상/간격 변경에 반응하여 애니메이션을 관리합니다.
|
||||||
*/
|
*/
|
||||||
@@ -626,6 +643,20 @@ export default React.memo(function GisTree({
|
|||||||
<Layers size={14} strokeWidth={2} />
|
<Layers size={14} strokeWidth={2} />
|
||||||
</span>
|
</span>
|
||||||
<h3 className="text-[1.4rem] font-bold text-foreground">레이어 관리</h3>
|
<h3 className="text-[1.4rem] font-bold text-foreground">레이어 관리</h3>
|
||||||
|
{showBulkLayerToggle && (
|
||||||
|
<button type="button" role="switch" aria-label="전체 레이어"
|
||||||
|
aria-checked={bulkLayerState.allChecked}
|
||||||
|
aria-description={bulkLayerState.partiallyChecked ? "일부 레이어 켜짐. 누르면 전체를 켭니다." : undefined}
|
||||||
|
title={bulkLayerState.allChecked ? "레이어 전체 끄기" : "레이어 전체 켜기"}
|
||||||
|
disabled={!gisCore || bulkLayerState.disabled}
|
||||||
|
className="ml-auto flex shrink-0 items-center gap-1 rounded-sm text-[1.1rem] focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
onClick={() => handleBulkLayerToggle(!bulkLayerState.allChecked)}>
|
||||||
|
<span>{bulkLayerState.partiallyChecked ? "일부 켜짐" : "전체"}</span>
|
||||||
|
<span aria-hidden="true" className={`relative inline-flex h-[18px] w-[32px] items-center rounded-full transition-colors ${bulkLayerState.allChecked ? "bg-[#168CFF]" : "bg-gray-300"}`}>
|
||||||
|
<span className={`h-[14px] w-[14px] rounded-full bg-white shadow-sm transition-transform ${bulkLayerState.allChecked ? "translate-x-[16px]" : "translate-x-[2px]"}`} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/** 화면에 표시하도록 허용된 트리 노드만 전체 켜기 대상으로 선택한다. */
|
||||||
|
export function getBulkLayerSelection(nodes, enabled) {
|
||||||
|
const selectedNodes = enabled
|
||||||
|
? nodes.filter((node) => node.data?.exprsYn === "Y")
|
||||||
|
: [];
|
||||||
|
const checkedIds = selectedNodes.map((node) => node.id);
|
||||||
|
const selected = new Set(checkedIds);
|
||||||
|
return {
|
||||||
|
checkedIds,
|
||||||
|
selectedNodes,
|
||||||
|
uncheckedIds: nodes.filter((node) => !selected.has(node.id)).map((node) => node.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 개별 체크 변경도 전체 토글에 반영하며 숨김 노드는 집계에서 제외한다. */
|
||||||
|
export function getBulkLayerState(nodes, checkedIds) {
|
||||||
|
const visibleIds = nodes.filter((node) => node.data?.exprsYn === "Y").map((node) => node.id);
|
||||||
|
const checked = new Set(checkedIds);
|
||||||
|
const count = visibleIds.filter((id) => checked.has(id)).length;
|
||||||
|
return {
|
||||||
|
disabled: visibleIds.length === 0,
|
||||||
|
allChecked: visibleIds.length > 0 && count === visibleIds.length,
|
||||||
|
partiallyChecked: count > 0 && count < visibleIds.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user