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와 기타 프론트엔드 장시간 실행 프로세스는 작업 종료 전에 반드시 종료한다.
|
||||
- 셸, 백그라운드, 숨김 창 등 VSCode 밖에서 에이전트가 기동한 프로세스도 종료 대상이다.
|
||||
|
||||
@@ -18,11 +18,13 @@ import {
|
||||
AGED_PIPE_LIST_HEADLIST,
|
||||
AGED_PIPE_PAGE_SIZE,
|
||||
buildAgedPipeCqlFilters,
|
||||
getAgedPipeManagementName,
|
||||
normalizeAgedPipeFilters,
|
||||
toDisplayPipeRow,
|
||||
validateElapsedYears,
|
||||
type AgedPipeFilters,
|
||||
type AgedPipeRow,
|
||||
type AgedPipeManagementAgency,
|
||||
} from "./agedPipeUtils";
|
||||
import { downloadAgedPipeExcel } from "./agedPipeExport";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
@@ -53,6 +55,15 @@ export default function AgedPipePopupPage() {
|
||||
const [appliedFilters, setAppliedFilters] =
|
||||
useState<AgedPipeFilters>(initialFilters);
|
||||
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 [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -73,6 +84,19 @@ export default function AgedPipePopupPage() {
|
||||
if (sgg.length === 0) fetchSgg();
|
||||
}, [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(
|
||||
async (page: number, nextFilters: AgedPipeFilters, size: number) => {
|
||||
const normalized = normalizeAgedPipeFilters(nextFilters);
|
||||
@@ -247,31 +271,6 @@ export default function AgedPipePopupPage() {
|
||||
) : null}
|
||||
|
||||
<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">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||
시도
|
||||
@@ -316,6 +315,31 @@ export default function AgedPipePopupPage() {
|
||||
</select>
|
||||
</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">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -341,7 +365,7 @@ export default function AgedPipePopupPage() {
|
||||
</PopupSearchPanel>
|
||||
|
||||
<MapPopupDataTable
|
||||
rows={rows as Array<AgedPipeRow & Record<string, unknown>>}
|
||||
rows={displayRows}
|
||||
headlist={AGED_PIPE_LIST_HEADLIST}
|
||||
title="노후관로 목록"
|
||||
total={total}
|
||||
|
||||
@@ -56,15 +56,34 @@ const normalizeRegionFilterCode = (value: unknown, prefixLength: number) => {
|
||||
|
||||
export const AGED_PIPE_LIST_HEADLIST = [
|
||||
{ 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: "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: "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 = [
|
||||
"ftreSmbolCd",
|
||||
"fcltMngNo",
|
||||
|
||||
+2
-2
@@ -4,10 +4,10 @@
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { getSnRowNumber } from "@/shared/lib/row-number";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Input } from "@common/components";
|
||||
import {
|
||||
getStandardRowNumber,
|
||||
StandardDataTable,
|
||||
StandardPopupLayout,
|
||||
} from "@common/business_components";
|
||||
@@ -71,7 +71,7 @@ export default function AgedPipeStatusByCtpvPopupPage() {
|
||||
id: "rowNumber",
|
||||
header: "순번",
|
||||
cell: ({ row }) =>
|
||||
getStandardRowNumber({
|
||||
getSnRowNumber({
|
||||
total: rows.length,
|
||||
rowIndex: row.index,
|
||||
}),
|
||||
|
||||
@@ -159,7 +159,7 @@ export const toFloodDngrMapTableRows = (
|
||||
): FloodDngrMapTableRow[] =>
|
||||
items.map((item, index) => ({
|
||||
...item,
|
||||
rowNo: total - page * pageSize - index,
|
||||
rowNo: page * pageSize + index + 1,
|
||||
ctpvNm: resolveAdministrativeName(item.ctpvCd, ctpvOptions, 2),
|
||||
sggNm: resolveAdministrativeName(item.sggCd, sggOptions, 5),
|
||||
sbmrgDpthlDisplay: toDepthRangeDisplay(
|
||||
|
||||
@@ -57,7 +57,7 @@ const GIS_UPLOAD_HISTORY_HEADLIST: UiHeadProps[] = [
|
||||
];
|
||||
|
||||
|
||||
/** 오류 원본 행번호와 공통 표의 역순 순번을 별도 열로 표시한다. */
|
||||
/** 오류 원본 행번호와 공통 표의 오름차순 표시 순번을 별도 열로 표시한다. */
|
||||
const GIS_VALIDATION_ERROR_HEADLIST: UiHeadProps[] = [
|
||||
{ id: "no", title: "순번", widthPercent: 8 },
|
||||
{ id: "sourceRowNo", title: "행번호", widthPercent: 10 },
|
||||
|
||||
@@ -160,7 +160,7 @@ export default function InftwtResultPopupPage() {
|
||||
return {
|
||||
...(formattedItem as Record<string, unknown>),
|
||||
rowNumber: Math.max(
|
||||
totalElements - ((page - 1) * INFTWT_RESULT_PAGE_SIZE + index),
|
||||
(page - 1) * INFTWT_RESULT_PAGE_SIZE + index + 1,
|
||||
0,
|
||||
),
|
||||
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
|
||||
|
||||
@@ -334,7 +334,7 @@ export default function PipeAnalysisPopupPage() {
|
||||
() =>
|
||||
yearMaterialGroups.map((group, index) => ({
|
||||
...group,
|
||||
rowNo: yearMaterialGroups.length - index,
|
||||
rowNo: index + 1,
|
||||
})),
|
||||
[yearMaterialGroups],
|
||||
);
|
||||
|
||||
@@ -158,7 +158,7 @@ export default function PipeAnalysisSummaryTable({
|
||||
if (summaryRow.kind === "flat") {
|
||||
const value =
|
||||
head.id === "rowNo"
|
||||
? total - ((page - 1) * pageSize + summaryRow.sourceIndex)
|
||||
? (page - 1) * pageSize + summaryRow.sourceIndex + 1
|
||||
: summaryRow.value[head.id];
|
||||
const onClick =
|
||||
summaryRow.value.__cellFilters[head.id] && Number(value) > 0
|
||||
|
||||
@@ -162,7 +162,7 @@ export default function RdiiResultPopupPage() {
|
||||
return {
|
||||
...(formattedItem as Record<string, unknown>),
|
||||
rowNumber: Math.max(
|
||||
totalElements - ((page - 1) * RDII_RESULT_PAGE_SIZE + index),
|
||||
(page - 1) * RDII_RESULT_PAGE_SIZE + index + 1,
|
||||
0,
|
||||
),
|
||||
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
|
||||
|
||||
@@ -139,7 +139,7 @@ export const toSbmrgDngrMapTableRows = (
|
||||
): SbmrgDngrMapTableRow[] =>
|
||||
items.map((item, index) => ({
|
||||
...item,
|
||||
rowNo: total - page * pageSize - index,
|
||||
rowNo: page * pageSize + index + 1,
|
||||
ctpvNm: resolveAdministrativeNames(item.ctpvCd, ctpvOptions, 2),
|
||||
sggNm: resolveAdministrativeNames(item.sggCd, sggOptions, 5),
|
||||
floodWtlvFrqVlDisplay: toDisplay(item.floodWtlvFrqVl),
|
||||
|
||||
@@ -7692,6 +7692,7 @@ const Map = () => {
|
||||
detachedTreeToggleButton={true}
|
||||
showLayerPanelTitle={false}
|
||||
sectionedTreePanel={true}
|
||||
showBulkLayerToggle={true}
|
||||
isPlaying={isPlaying}
|
||||
setIsPlaying={setIsPlaying as any}
|
||||
targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any}
|
||||
|
||||
@@ -405,7 +405,7 @@ const ReportTab: React.FC<ReportTabProps> = ({
|
||||
.slice(pageStart, pageEnd)
|
||||
.map((item, index) => ({
|
||||
...item,
|
||||
no: totalCount - (pageOffset + index),
|
||||
no: pageOffset + index + 1,
|
||||
}));
|
||||
|
||||
reportRows = await enrichReportRows(pagedReportData);
|
||||
|
||||
@@ -152,7 +152,7 @@ export default function useBasicTab() {
|
||||
// 추가 처리 및 일련번호 추가
|
||||
return {
|
||||
...formattedItem,
|
||||
rowNumber: totalCount - ((curPage - 1) * 10 + index),
|
||||
rowNumber: (curPage - 1) * 10 + index + 1,
|
||||
scsYn:
|
||||
item.scsYn === null || item.scsYn === undefined ? "N" : item?.scsYn, // scsYn이 null이면 'N'으로 변경
|
||||
mqltCd: matchedMqltCode ? matchedMqltCode.text : item?.mqltCd,
|
||||
|
||||
@@ -174,7 +174,7 @@ export default function useOperationTab() {
|
||||
return {
|
||||
...formattedItem,
|
||||
rowNumber:
|
||||
totalElements - ((curPage - 1) * OPERATION_TABLE_SIZE + index),
|
||||
(curPage - 1) * OPERATION_TABLE_SIZE + index + 1,
|
||||
bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd,
|
||||
};
|
||||
},
|
||||
@@ -233,7 +233,7 @@ export default function useOperationTab() {
|
||||
);
|
||||
return {
|
||||
...formattedItem,
|
||||
rowNumber: totalDetailElements - detailIndex,
|
||||
rowNumber: detailIndex + 1,
|
||||
usgCd: usgCdObj ? usgCdObj.text : item.usgCd,
|
||||
mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd,
|
||||
swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : "-",
|
||||
@@ -325,8 +325,7 @@ export default function useOperationTab() {
|
||||
return {
|
||||
...formattedItem,
|
||||
rowNumber:
|
||||
totalElements -
|
||||
((detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex),
|
||||
(detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex + 1,
|
||||
usgCd: usgCdObj ? usgCdObj.text : item.usgCd,
|
||||
mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd,
|
||||
swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : "-",
|
||||
|
||||
@@ -116,7 +116,7 @@ export default function useSearchModal() {
|
||||
return {
|
||||
...formattedItem,
|
||||
rowNumber:
|
||||
totalElements - ((curPage - 1) * OPERATION_TABLE_SIZE + index),
|
||||
(curPage - 1) * OPERATION_TABLE_SIZE + index + 1,
|
||||
bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -170,7 +170,7 @@ const Result = () => {
|
||||
|
||||
const totalElements = Number(data?.totalElements ?? 0);
|
||||
|
||||
// 전체 건수를 기준으로 현재 페이지의 순번을 내림차순 계산한다.
|
||||
// 현재 페이지의 시작 위치를 기준으로 오름차순 표시 순번을 계산한다.
|
||||
const dataWithRowNumber = (data.content || [])
|
||||
.map((item: any, index: number) => {
|
||||
// 🎯 먼저 각 아이템을 포맷팅
|
||||
@@ -179,11 +179,11 @@ const Result = () => {
|
||||
(code) => code.val.trim() === item?.ctpvCd?.trim(),
|
||||
);
|
||||
const sggOne = sgg.find((code) => code.val === item.sggCd);
|
||||
// 추가 처리 및 전체 건수 기준 순번 추가
|
||||
// 추가 처리 및 페이지 연속 순번 추가
|
||||
return {
|
||||
...formattedItem,
|
||||
rowNumber: Math.max(
|
||||
totalElements - (((options?.page ?? curPage) - 1) * 10 + index),
|
||||
((options?.page ?? curPage) - 1) * 10 + index + 1,
|
||||
0,
|
||||
),
|
||||
scsYn:
|
||||
|
||||
@@ -170,7 +170,7 @@ const Result = () => {
|
||||
|
||||
const totalElements = Number(data?.totalElements ?? 0);
|
||||
|
||||
// 전체 건수를 기준으로 현재 페이지의 순번을 내림차순 계산한다.
|
||||
// 현재 페이지의 시작 위치를 기준으로 오름차순 표시 순번을 계산한다.
|
||||
const dataWithRowNumber = (data.content || [])
|
||||
.map((item: any, index: number) => {
|
||||
// 🎯 먼저 각 아이템을 포맷팅
|
||||
@@ -179,11 +179,11 @@ const Result = () => {
|
||||
(code) => code.val.trim() === item?.ctpvCd?.trim(),
|
||||
);
|
||||
const sggOne = sgg.find((code) => code.val === item.sggCd);
|
||||
// 추가 처리 및 전체 건수 기준 순번 추가
|
||||
// 추가 처리 및 페이지 연속 순번 추가
|
||||
return {
|
||||
...formattedItem,
|
||||
rowNumber: Math.max(
|
||||
totalElements - (((options?.page ?? curPage) - 1) * 10 + index),
|
||||
((options?.page ?? curPage) - 1) * 10 + index + 1,
|
||||
0,
|
||||
),
|
||||
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 filteredRecords.map((record, index) => ({
|
||||
rowNumber: filteredRecords.length - index,
|
||||
rowNumber: index + 1,
|
||||
...record,
|
||||
pipeLengthM: Number(record.pipeLengthM.toFixed(2)),
|
||||
pipeMaterial: resolvePipeMaterialName(record.pipeMaterial),
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
* - 첨부파일 삭제및 Soft Delete 삭제: DELETE /api/v1/atfl/attachments/{atflId} (attach-service)
|
||||
*/
|
||||
"use client";
|
||||
import { getSnRowNumber } from "@/shared/lib/row-number";
|
||||
import { deleteData, useCookieAuth } from "@/components/lib/api";
|
||||
import { searchData } from "@/components/lib/common/apiHelpers";
|
||||
import { formatDate, handleDownload } from "@/components/lib/utils";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import {
|
||||
StandardDataTable,
|
||||
getStandardRowNumber,
|
||||
} from "@common/business_components";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -192,7 +192,7 @@ const Page = () => {
|
||||
const dataWithRowNumber = (data || []).map(
|
||||
(item: any, index: number) => ({
|
||||
...item,
|
||||
rowNumber: getStandardRowNumber({
|
||||
rowNumber: getSnRowNumber({
|
||||
total,
|
||||
rowIndex: index,
|
||||
pageIndex: page - 1,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { getSnRowNumber } from "@/shared/lib/row-number";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
@@ -22,7 +23,6 @@ import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
getStandardRowNumber,
|
||||
StandardDataTable,
|
||||
} from "@common/business_components";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
@@ -125,7 +125,7 @@ const Page = () => {
|
||||
setRows(
|
||||
(result.data ?? []).map((item: OldPpRow, rowIndex: number) => ({
|
||||
...item,
|
||||
rowNumber: getStandardRowNumber({
|
||||
rowNumber: getSnRowNumber({
|
||||
total: result.total,
|
||||
rowIndex,
|
||||
pageIndex: targetPage - 1,
|
||||
|
||||
@@ -104,7 +104,7 @@ function filterEmptyRows(data: ExcelRow[]) {
|
||||
function withDescendingRowNumber(rows: ExcelRow[]) {
|
||||
return rows.map((row, index) => ({
|
||||
...row,
|
||||
rowNumber: rows.length - index,
|
||||
rowNumber: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { cookies } from "next/headers";
|
||||
import { AppProvider } from "./providers";
|
||||
import "../src/app/styles/base.css";
|
||||
import "../src/app/styles/components.css";
|
||||
import "../src/app/styles/grid-ellipsis.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "디지털 하수관로",
|
||||
@@ -35,7 +36,7 @@ export default async function RootLayout({
|
||||
|
||||
return (
|
||||
<html lang="ko" data-theme={theme?.value}>
|
||||
<body>
|
||||
<body data-sn-grid-ellipsis="true">
|
||||
<AppProvider>{children}</AppProvider>
|
||||
</body>
|
||||
</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 코드값 또는 코드명과 정확히 일치하는 값만 코드명으로 표시하고 미등록 기존 값은 그대로 표시한다.
|
||||
- 영문 `lwpoCd` 저장 전환과 기존 데이터 이관은 이번 범위에서 수행하지 않았다.
|
||||
- 백엔드 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",
|
||||
"start:sn": "next start -p 3002",
|
||||
"lint": "next lint",
|
||||
"test:refactor": "node scripts/run-refactor-tests.cjs",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -13,6 +13,7 @@ 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";
|
||||
import { GridCellTooltip } from "@/shared/ui/grid-cell-tooltip";
|
||||
|
||||
// 개발 도구는 실제 로컬 개발 환경에서만 별도 청크로 불러와 최초 페이지 컴파일 범위를 줄인다.
|
||||
const LazyReactQueryDevtools = dynamic(
|
||||
@@ -30,6 +31,7 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<StoreProvider>
|
||||
<BusinessUserMessageBridge />
|
||||
<GridCellTooltip />
|
||||
<PromptProvider>
|
||||
<ConfirmProvider>
|
||||
<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";
|
||||
|
||||
import { getSnRowNumber } from "@/shared/lib/row-number";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
@@ -10,7 +11,6 @@ import useOnsemiroAddrStore from "@/app/store/useOnsemiroAddrStore";
|
||||
import {
|
||||
StandardDataTable,
|
||||
StandardPopupLayout,
|
||||
getStandardRowNumber,
|
||||
} from "@common/business_components";
|
||||
import {
|
||||
Button,
|
||||
@@ -273,7 +273,7 @@ export default function CfmtnBgtPopupContent({
|
||||
() =>
|
||||
(Array.isArray(data.content) ? data.content : []).map((row, index) => ({
|
||||
...row,
|
||||
rowNumber: getStandardRowNumber({
|
||||
rowNumber: getSnRowNumber({
|
||||
total: data.totalElements,
|
||||
rowIndex: index,
|
||||
pageIndex: page,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { getSnRowNumber } from "@/shared/lib/row-number";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import {
|
||||
StandardDataTable,
|
||||
StandardTableSortHeader,
|
||||
getStandardRowNumber,
|
||||
type UiTableSort,
|
||||
} from "@common/business_components";
|
||||
import {
|
||||
@@ -463,7 +463,7 @@ export function CtyFldngPrvtnBizListPage() {
|
||||
result.content.map((row, i) =>
|
||||
flattenRow(
|
||||
row,
|
||||
getStandardRowNumber({
|
||||
getSnRowNumber({
|
||||
total: result.totalElements,
|
||||
rowIndex: i,
|
||||
pageIndex: target - 1,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { getSnRowNumber } from "@/shared/lib/row-number";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { fileDownload } from "@/components/lib/utils";
|
||||
import { getStartYearArray } from "@/components/ui/YearRangeSelectBox";
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
STANDARD_ROW_NUMBER_HEADER,
|
||||
StandardDataTable,
|
||||
StandardTableSortHeader,
|
||||
getStandardRowNumber,
|
||||
type UiTableSort,
|
||||
} from "@common/business_components";
|
||||
import {
|
||||
@@ -146,7 +146,7 @@ export function ExmnRegistrationPage() {
|
||||
() =>
|
||||
businesses.map((business, index) => ({
|
||||
...business,
|
||||
rowNumber: getStandardRowNumber({
|
||||
rowNumber: getSnRowNumber({
|
||||
total: totalElements,
|
||||
rowIndex: index,
|
||||
pageIndex: pagination.pageIndex,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { getSnRowNumber } from "@/shared/lib/row-number";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
@@ -9,7 +10,6 @@ import {
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
StandardDataTable,
|
||||
getStandardRowNumber,
|
||||
type ScrollMode,
|
||||
type UiHeadProps,
|
||||
} from "@common/business_components";
|
||||
@@ -43,7 +43,7 @@ const formatValue = (value: unknown) => {
|
||||
: String(value);
|
||||
};
|
||||
|
||||
/** 조사관리 표를 CS 목록 규격과 역순 순번 계약으로 연결한다. */
|
||||
/** 조사관리 표를 CS 목록 규격과 페이지 연속 오름차순 순번으로 연결한다. */
|
||||
export function ExmnStandardDataTable<T extends Record<string, unknown>>({
|
||||
rows,
|
||||
headlist,
|
||||
@@ -69,7 +69,7 @@ export function ExmnStandardDataTable<T extends Record<string, unknown>>({
|
||||
header: ROW_NUMBER_IDS.has(head.id) ? "순번" : head.title,
|
||||
cell: ({ row, getValue }) => {
|
||||
const value = ROW_NUMBER_IDS.has(head.id)
|
||||
? getStandardRowNumber({
|
||||
? getSnRowNumber({
|
||||
total,
|
||||
rowIndex: row.index,
|
||||
pageIndex: page - 1,
|
||||
|
||||
@@ -189,7 +189,7 @@ export function ExmnStatusDetailPanel({
|
||||
const counts = getCounts(row);
|
||||
return {
|
||||
trtdstId: row.trtdstId,
|
||||
rowNumber: summaryRows.length - index,
|
||||
rowNumber: index + 1,
|
||||
trtdstNm: row.trtdstNm,
|
||||
trgtPrlgText: formatNumber(row.trgtPrlg),
|
||||
drvngDstncText: formatNumber(row.drvngDstnc),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { getSnRowNumber } from "@/shared/lib/row-number";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import useStore from "@/store/useStore";
|
||||
import {
|
||||
STANDARD_ROW_NUMBER_HEADER,
|
||||
StandardDataTable,
|
||||
StandardTableSortHeader,
|
||||
getStandardRowNumber,
|
||||
sortStandardTableRows,
|
||||
type UiTableSort,
|
||||
} from "@common/business_components";
|
||||
@@ -73,16 +73,16 @@ const createLocalKey = () =>
|
||||
? `client:${crypto.randomUUID()}`
|
||||
: `client:${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
/** 화면의 현재 행 순서를 전체 건수에서 시작하는 공통 내림차순 순번으로 정규화한다. */
|
||||
const withDescendingRowNumbers = (rows: TrtdstTableRow[]): TrtdstTableRow[] =>
|
||||
/** 화면의 현재 행 순서에 1부터 오름차순 표시 순번을 붙인다. */
|
||||
const withAscendingRowNumbers = (rows: TrtdstTableRow[]): TrtdstTableRow[] =>
|
||||
rows.map((row, index) => ({
|
||||
...row,
|
||||
rowNumber: getStandardRowNumber({ total: rows.length, rowIndex: index }),
|
||||
rowNumber: getSnRowNumber({ total: rows.length, rowIndex: index }),
|
||||
}));
|
||||
|
||||
/** 서버 처리분구 목록을 공통 표와 변경 추적에 필요한 행 구조로 변환한다. */
|
||||
const toTableRows = (items: TrtdstSettingDto[]): TrtdstTableRow[] =>
|
||||
withDescendingRowNumbers(
|
||||
withAscendingRowNumbers(
|
||||
items.map((item) => ({
|
||||
...item,
|
||||
trtdstId: String(item.trtdstId),
|
||||
@@ -103,7 +103,7 @@ const toSavedTableRows = (
|
||||
rows: TrtdstTableRow[],
|
||||
savedItems: TrtdstSettingDto[] = [],
|
||||
): TrtdstTableRow[] =>
|
||||
withDescendingRowNumbers(
|
||||
withAscendingRowNumbers(
|
||||
rows
|
||||
.filter((row) => row.rowStatus !== "deletePending")
|
||||
.map((row) => {
|
||||
@@ -300,7 +300,7 @@ export function TrtdstManagementPage() {
|
||||
setSort(undefined);
|
||||
setPagination((current) => ({ ...current, pageIndex: 0 }));
|
||||
setRows((current) =>
|
||||
withDescendingRowNumbers([
|
||||
withAscendingRowNumbers([
|
||||
...current,
|
||||
{
|
||||
trtdstId: "",
|
||||
@@ -353,7 +353,7 @@ export function TrtdstManagementPage() {
|
||||
if (selectedRow.rowStatus === "added") {
|
||||
if (!(await confirm("선택한 처리분구를 삭제하시겠습니까?"))) return;
|
||||
setRows((current) =>
|
||||
withDescendingRowNumbers(
|
||||
withAscendingRowNumbers(
|
||||
current.filter((row) => row.localKey !== selectedRow.localKey),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -33,7 +33,7 @@ const SHEET_CONTRACTS: SheetContract[] = [
|
||||
|
||||
const DATA_START_ROW_INDEX = 7;
|
||||
const FIRST_DATA_COLUMN_INDEX = 1;
|
||||
const DATA_COLUMN_COUNT = 21;
|
||||
const DATA_COLUMN_COUNT = 20;
|
||||
const DOWNLOAD_FILENAME = "노후하수관로 정밀조사 및 정비사업 현황.xlsx";
|
||||
|
||||
/** API 문자열에 남은 Excel 개행 이스케이프를 실제 줄바꿈으로 정리한다. */
|
||||
@@ -63,7 +63,7 @@ const validateHeaders = (sheet: XLSX.WorkSheet, contract: SheetContract) => {
|
||||
[6, 14, "증빙서류제출여부"], [5, 15, contract.progressHeader],
|
||||
[6, 15, contract.startDateHeader], [6, 16, "준공(예정)일자(yyyy.mm.dd)"],
|
||||
[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) {
|
||||
@@ -116,7 +116,7 @@ const dateText = (value: string): string | null => {
|
||||
return (digits || normalized).slice(0, 8);
|
||||
};
|
||||
|
||||
/** B~V가 모두 비어 있는 행은 데이터가 아닌 양식 여백으로 판단한다. */
|
||||
/** B~U가 모두 비어 있는 행은 데이터가 아닌 양식 여백으로 판단한다. */
|
||||
const isEmptyDataRow = (sheet: XLSX.WorkSheet, rowIndex: number) =>
|
||||
Array.from({ length: DATA_COLUMN_COUNT }, (_, 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)),
|
||||
bizPicCnpl: optionalText(valueAt(18)),
|
||||
rmrkCn: optionalText(valueAt(19)),
|
||||
pbcpOpnnCn: optionalText(valueAt(20)),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
@@ -183,27 +182,27 @@ export const parseObsltPplExcel = async (file: File): Promise<ObsltPplBusinessSa
|
||||
return parseObsltPplWorkbook(await file.arrayBuffer());
|
||||
};
|
||||
|
||||
/** 업로드 검증과 정확히 같은 B6:V7 위치에 시트별 헤더를 배치한다. */
|
||||
/** 업로드 검증과 정확히 같은 B6:U7 위치에 시트별 헤더를 배치한다. */
|
||||
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 = [
|
||||
"순번", "정밀\n조사\n차수", contract.businessCodeHeader.replace("사업코드", "\n사업코드"),
|
||||
"시도", "시군", "환경청", "사업명", "추진현황", "착수\n년도", "준공(예정)\n년도",
|
||||
contract.extensionHeader, "", "", "", contract.progressHeader.replace("추진현황", " 추진현황"),
|
||||
"", "사업추진 부진 사유", "사업 담당자(지자체)", "", "비고", "",
|
||||
"", "사업추진 부진 사유", "사업 담당자(지자체)", "", "비고",
|
||||
];
|
||||
const bottomHeaders = [
|
||||
"", "", "", "", "", "", "", "", "", "", "계획연장(km)", "상반기(예상)연장(km)",
|
||||
"잔여연장(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; });
|
||||
return rows;
|
||||
};
|
||||
|
||||
/** DB에 저장하지 않는 B열 순번을 시트 출력 순서로 만들고 C~V에 업무값을 배치한다. */
|
||||
/** DB에 저장하지 않는 B열 순번을 시트 출력 순서로 만들고 C~U에 업무값을 배치한다. */
|
||||
const downloadDataRow = (row: ObsltPplBusiness, index: number): Array<string | number> => [
|
||||
"",
|
||||
index + 1,
|
||||
@@ -226,7 +225,6 @@ const downloadDataRow = (row: ObsltPplBusiness, index: number): Array<string | n
|
||||
excelTextValue(row.bizPicNm),
|
||||
excelTextValue(row.bizPicCnpl),
|
||||
excelTextValue(row.rmrkCn),
|
||||
excelTextValue(row.pbcpOpnnCn),
|
||||
];
|
||||
|
||||
/** 조회 결과를 정밀조사·정비사업 두 시트의 재업로드 가능한 위치 구조로 생성한다. */
|
||||
@@ -266,7 +264,7 @@ export const buildObsltPplWorkbook = (businesses: ObsltPplBusiness[]): XLSX.Work
|
||||
];
|
||||
const widths = [
|
||||
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 }));
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, contract.sheetName);
|
||||
@@ -304,7 +302,7 @@ export const downloadObsltPplExcel = async (
|
||||
fill: "FFF2CC",
|
||||
border: true,
|
||||
});
|
||||
styledSheet.range("B6:V7").style({
|
||||
styledSheet.range("B6:U7").style({
|
||||
bold: true,
|
||||
fontFamily: "맑은 고딕",
|
||||
fontSize: 10,
|
||||
@@ -315,7 +313,7 @@ export const downloadObsltPplExcel = async (
|
||||
});
|
||||
styledSheet.range("B6:H7").style("fill", "B4C6E7");
|
||||
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(3).height(26);
|
||||
styledSheet.row(5).height(22);
|
||||
@@ -325,7 +323,7 @@ export const downloadObsltPplExcel = async (
|
||||
if (rowCount > 0) {
|
||||
const dataStartRow = DATA_START_ROW_INDEX + 1;
|
||||
const dataEndRow = DATA_START_ROW_INDEX + rowCount;
|
||||
styledSheet.range(`B${dataStartRow}:V${dataEndRow}`).style({
|
||||
styledSheet.range(`B${dataStartRow}:U${dataEndRow}`).style({
|
||||
fontFamily: "맑은 고딕",
|
||||
fontSize: 10,
|
||||
horizontalAlignment: "center",
|
||||
@@ -336,7 +334,7 @@ export const downloadObsltPplExcel = async (
|
||||
styledSheet
|
||||
.range(`L${dataStartRow}:N${dataEndRow}`)
|
||||
.style("numberFormat", "#,##0.###");
|
||||
["H", "R", "U", "V"].forEach((column) =>
|
||||
["H", "R", "U"].forEach((column) =>
|
||||
styledSheet
|
||||
.range(`${column}${dataStartRow}:${column}${dataEndRow}`)
|
||||
.style({ horizontalAlignment: "left", wrapText: true }),
|
||||
|
||||
@@ -22,7 +22,6 @@ export interface ObsltPplBusiness {
|
||||
bizPicNm?: string | null;
|
||||
bizPicCnpl?: string | null;
|
||||
rmrkCn?: string | null;
|
||||
pbcpOpnnCn?: string | null;
|
||||
regDt?: string | null;
|
||||
mdfcnDt?: string | null;
|
||||
}
|
||||
|
||||
@@ -79,7 +79,6 @@ const FIELDS: Array<{
|
||||
{ key: "bizPicNm", label: "담당자 이름" },
|
||||
{ key: "bizPicCnpl", label: "담당자 연락처" },
|
||||
{ key: "rmrkCn", label: "비고", type: "textarea" },
|
||||
{ key: "pbcpOpnnCn", label: "공단 의견", type: "textarea" },
|
||||
];
|
||||
|
||||
const NUMBER_KEYS = new Set<FormKey>([
|
||||
@@ -530,7 +529,6 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
|
||||
{renderField("bizPicCnpl", 2)}
|
||||
{renderField("bizPrplDlyRsnCn", 3)}
|
||||
{renderField("rmrkCn", 3)}
|
||||
{renderField("pbcpOpnnCn", 3)}
|
||||
</StandardFormSection>
|
||||
|
||||
<div className="flex justify-center gap-4 pb-8 pt-6">
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { getSnRowNumber } from "@/shared/lib/row-number";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import {
|
||||
StandardDataTable,
|
||||
StandardTableSortHeader,
|
||||
getStandardRowNumber,
|
||||
type UiTableSort,
|
||||
} from "@common/business_components";
|
||||
import {
|
||||
@@ -65,8 +65,10 @@ type ObsltPplTableColumn = {
|
||||
};
|
||||
type ObsltPplSearch = {
|
||||
bizSe: string;
|
||||
bizCd: string;
|
||||
ctpvNm: string;
|
||||
sigunNm: string;
|
||||
prplPrst: string;
|
||||
bizNm: string;
|
||||
};
|
||||
|
||||
@@ -120,7 +122,6 @@ const TABLE_COLUMNS: ObsltPplTableColumn[] = [
|
||||
{ id: "bizPicNm", title: "담당자 이름", width: 90 },
|
||||
{ id: "bizPicCnpl", title: "담당자 연락처", width: 120 },
|
||||
{ id: "rmrkCn", title: "비고", width: 160, align: "left" },
|
||||
{ id: "pbcpOpnnCn", title: "공단\n의견", width: 90, align: "left" },
|
||||
];
|
||||
const TABLE_MIN_WIDTH = "280rem";
|
||||
|
||||
@@ -154,6 +155,7 @@ export function ObsltPplListPage() {
|
||||
// 검색값·원본 행은 유지하고 사업구분·추진·증빙 표시명만 CMS 기준으로 변환한다.
|
||||
const {
|
||||
obsltBusinessTypeOptions,
|
||||
businessProgressOptions,
|
||||
evidenceStatusLabels,
|
||||
businessProgressLabels,
|
||||
} = useOperBusinessCommonCodes();
|
||||
@@ -162,8 +164,10 @@ export function ObsltPplListPage() {
|
||||
const initialSearch = useMemo<ObsltPplSearch>(
|
||||
() => ({
|
||||
bizSe: searchParams.get("bizSe") ?? "정밀조사",
|
||||
bizCd: searchParams.get("bizCd") ?? "",
|
||||
ctpvNm: searchParams.get("ctpvNm") ?? "",
|
||||
sigunNm: searchParams.get("sigunNm") ?? "",
|
||||
prplPrst: searchParams.get("prplPrst") ?? "",
|
||||
bizNm: searchParams.get("bizNm") ?? "",
|
||||
}),
|
||||
[searchParams],
|
||||
@@ -331,7 +335,7 @@ export function ObsltPplListPage() {
|
||||
setRows(
|
||||
result.content.map((row, index) => ({
|
||||
...formatBusinessForDisplay(row),
|
||||
rowNumber: getStandardRowNumber({
|
||||
rowNumber: getSnRowNumber({
|
||||
total: result.totalElements,
|
||||
rowIndex: index,
|
||||
pageIndex: targetPage - 1,
|
||||
@@ -454,8 +458,10 @@ export function ObsltPplListPage() {
|
||||
const handleReset = () => {
|
||||
const nextSearch: ObsltPplSearch = {
|
||||
bizSe: "정밀조사",
|
||||
bizCd: "",
|
||||
ctpvNm: "",
|
||||
sigunNm: "",
|
||||
prplPrst: "",
|
||||
bizNm: "",
|
||||
};
|
||||
setSearch(nextSearch);
|
||||
@@ -599,6 +605,54 @@ export function ObsltPplListPage() {
|
||||
</Select>
|
||||
</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">
|
||||
<Label
|
||||
htmlFor="obslt-ppl-biz-name"
|
||||
|
||||
@@ -140,10 +140,7 @@ export const windowPopupDefinitions = {
|
||||
title: "노후 하수관로 목록",
|
||||
width: 1160,
|
||||
height: 820,
|
||||
placement: "browser-left",
|
||||
alignTop: true,
|
||||
offsetLeft: 0,
|
||||
offsetTop: 70,
|
||||
// 다른 목록 팝업과 동일한 공통 중앙 배치를 사용한다.
|
||||
},
|
||||
sinkholePopup: {
|
||||
key: "sinkholePopup",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { getSnRowNumber } from "@/shared/lib/row-number";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
|
||||
import {
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
getStandardRowNumber,
|
||||
type ScrollMode,
|
||||
StandardDataTable,
|
||||
StandardTableSortHeader,
|
||||
@@ -109,7 +109,7 @@ export default function MapPopupDataTable<T extends Record<string, unknown>>({
|
||||
const rowIndex = row.index;
|
||||
const rendered = renderCell?.(row.original, head.id, rowIndex);
|
||||
const value = ROW_NUMBER_IDS.has(head.id)
|
||||
? getStandardRowNumber({
|
||||
? getSnRowNumber({
|
||||
total,
|
||||
rowIndex,
|
||||
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,
|
||||
// sn 관망도처럼 트리와 선택 설정을 카드 두 개로 구분해야 하는 화면에서만 활성화한다.
|
||||
sectionedTreePanel = false,
|
||||
// 전체 체크 조작은 필요한 화면에서만 명시적으로 활성화한다.
|
||||
showBulkLayerToggle = false,
|
||||
showZoomScaleInfo = false,
|
||||
}) {
|
||||
const mapRef = useRef();
|
||||
@@ -345,6 +347,7 @@ function Gis({
|
||||
detachedToggleButton={detachedTreeToggleButton}
|
||||
showLayerPanelTitle={showLayerPanelTitle}
|
||||
sectionedPanel={sectionedTreePanel}
|
||||
showBulkLayerToggle={showBulkLayerToggle}
|
||||
/>
|
||||
|
||||
{typeof WaterDepth === "function" && (
|
||||
|
||||
@@ -10,6 +10,7 @@ import React, { useEffect, useLayoutEffect, useState, useRef, useCallback } from
|
||||
import { showBusinessUserMessage } from "../../lib/user-message";
|
||||
import { useGisContext } from "./Gis";
|
||||
import GisApp from "./lib/gis.core";
|
||||
import { getBulkLayerSelection, getBulkLayerState } from "./layerBulkSelection";
|
||||
|
||||
/**
|
||||
* GisTree (React memoized component)
|
||||
@@ -63,6 +64,7 @@ export default React.memo(function GisTree({
|
||||
showLayerPanelTitle = true,
|
||||
detachedToggleButton = false,
|
||||
sectionedPanel = false,
|
||||
showBulkLayerToggle = false,
|
||||
}) {
|
||||
const { gisCore, treeData } = useGisContext();
|
||||
|
||||
@@ -71,6 +73,8 @@ export default React.memo(function GisTree({
|
||||
const [expanded, setExpanded] = useState([]);
|
||||
const [nodes, setNodes] = useState([]);
|
||||
const [activeTab, setActiveTab] = useState("layers");
|
||||
// 전체 선택이면 ON, 부분 선택이면 OFF 위치와 별도 상태 문구로 구분한다.
|
||||
const bulkLayerState = getBulkLayerState(nodes, checked);
|
||||
|
||||
// 자동 애니메이션 관련 ref (실행 인덱스, 대상 노드 목록, 일시정지, 타이머)
|
||||
const currentAnimationIndexRef = useRef(0);
|
||||
@@ -470,6 +474,19 @@ export default React.memo(function GisTree({
|
||||
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} />
|
||||
</span>
|
||||
<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
|
||||
|
||||
@@ -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