Merge branch 'iipbs/sn/dev'
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { validateElapsedYears, type AgedPipeFilters } from "./agedPipeUtils";
|
||||
import LayerSettingCard from "../layerSetting/LayerSettingCard";
|
||||
|
||||
type MaybePromise<T = void> = T | Promise<T>;
|
||||
|
||||
@@ -11,6 +12,9 @@ type Props = {
|
||||
onFiltersChange: (filters: AgedPipeFilters) => void;
|
||||
onApply: (filters?: AgedPipeFilters) => MaybePromise;
|
||||
compact?: boolean;
|
||||
enabled?: boolean;
|
||||
onEnabledChange?: (enabled: boolean) => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export default function AgedPipeLayerSettingPanel({
|
||||
@@ -18,6 +22,9 @@ export default function AgedPipeLayerSettingPanel({
|
||||
onFiltersChange,
|
||||
onApply,
|
||||
compact = false,
|
||||
enabled = true,
|
||||
onEnabledChange = () => {},
|
||||
onClose = () => {},
|
||||
}: Props) {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
@@ -47,16 +54,16 @@ export default function AgedPipeLayerSettingPanel({
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
"h-[22px] border border-input bg-background px-[2px] text-center text-[1rem] leading-none text-foreground";
|
||||
"h-[20px] !rounded border border-input bg-background px-[1px] text-center text-[1.1rem] leading-none text-foreground";
|
||||
const numberInputClass = inputClass + " w-[40px]";
|
||||
const applyButtonClass =
|
||||
"grid h-[20px] w-[20px] shrink-0 place-items-center rounded-full bg-primary text-[0.95rem] font-bold leading-none text-primary-foreground";
|
||||
"grid h-[16px] w-[16px] shrink-0 place-items-center rounded-full bg-primary text-[0.85rem] font-bold leading-none text-primary-foreground";
|
||||
const indicator = (
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
);
|
||||
|
||||
const settingRow = (
|
||||
<div className="flex min-h-[25px] items-center gap-1 whitespace-nowrap text-[1.05rem] font-medium text-label">
|
||||
<div className="flex min-h-[26px] items-center gap-1 whitespace-nowrap text-[1.15rem] font-medium text-label">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{indicator}
|
||||
<label htmlFor="aged-pipe-elapsed-years" className="shrink-0">
|
||||
@@ -88,7 +95,7 @@ export default function AgedPipeLayerSettingPanel({
|
||||
title="적용"
|
||||
aria-label="경과년도 적용"
|
||||
>
|
||||
✓
|
||||
<span className="relative left-[1px]">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,9 +103,14 @@ export default function AgedPipeLayerSettingPanel({
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="w-full rounded-[6px] border border-border bg-background px-[6px] py-2 shadow-blur">
|
||||
<LayerSettingCard
|
||||
title="노후관로"
|
||||
enabled={enabled}
|
||||
onEnabledChange={onEnabledChange}
|
||||
onClose={onClose}
|
||||
>
|
||||
{settingRow}
|
||||
</div>
|
||||
</LayerSettingCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
type AgedPipeFilters,
|
||||
type AgedPipeRow,
|
||||
} from "./agedPipeUtils";
|
||||
import { downloadAgedPipeCsv } from "./agedPipeExport";
|
||||
import { downloadAgedPipeExcel } from "./agedPipeExport";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
|
||||
@@ -32,16 +32,22 @@ export default function AgedPipePopupPage() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const searchParams = useSearchParams();
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const [filters, setFilters] = useState<AgedPipeFilters>(() =>
|
||||
normalizeAgedPipeFilters({
|
||||
elapsedYears: Number(
|
||||
searchParams.get("elapsedYears") ??
|
||||
AGED_PIPE_DEFAULT_FILTERS.elapsedYears,
|
||||
),
|
||||
ctpvCd: searchParams.get("ctpvCd") ?? AGED_PIPE_DEFAULT_FILTERS.ctpvCd,
|
||||
sggCd: searchParams.get("sggCd") ?? AGED_PIPE_DEFAULT_FILTERS.sggCd,
|
||||
}),
|
||||
const initialFilters = useMemo(
|
||||
() =>
|
||||
normalizeAgedPipeFilters({
|
||||
elapsedYears: Number(
|
||||
searchParams.get("elapsedYears") ??
|
||||
AGED_PIPE_DEFAULT_FILTERS.elapsedYears,
|
||||
),
|
||||
ctpvCd: searchParams.get("ctpvCd") ?? AGED_PIPE_DEFAULT_FILTERS.ctpvCd,
|
||||
sggCd: searchParams.get("sggCd") ?? AGED_PIPE_DEFAULT_FILTERS.sggCd,
|
||||
}),
|
||||
[searchParams],
|
||||
);
|
||||
const [filters, setFilters] = useState<AgedPipeFilters>(initialFilters);
|
||||
// 화면 목록을 마지막으로 조회한 조건이며 페이지 이동·Excel이 동일하게 사용한다.
|
||||
const [appliedFilters, setAppliedFilters] =
|
||||
useState<AgedPipeFilters>(initialFilters);
|
||||
const [rows, setRows] = useState<AgedPipeRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -102,6 +108,7 @@ export default function AgedPipePopupPage() {
|
||||
setRows(response.rows);
|
||||
setTotal(response.total);
|
||||
setCurrentPage(page);
|
||||
setAppliedFilters(normalizeAgedPipeFilters(nextFilters));
|
||||
} catch (error) {
|
||||
console.error("노후관로 목록 조회 중 오류:", error);
|
||||
setRows([]);
|
||||
@@ -162,22 +169,36 @@ export default function AgedPipePopupPage() {
|
||||
alert("다운로드할 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
if (!validateElapsedYears(Number(filters.elapsedYears))) {
|
||||
alert("경과년도는 1년~100년 범위 내에서 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedDraft = normalizeAgedPipeFilters(filters);
|
||||
if (JSON.stringify(normalizedDraft) !== JSON.stringify(appliedFilters)) {
|
||||
alert("조회조건이 변경되었습니다. 조회 후 Excel을 다운로드해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setDownloadProgressText("CSV export 작업을 준비하고 있습니다.");
|
||||
await downloadAgedPipeCsv(filters, (job) => {
|
||||
if (job.status === "QUEUED") {
|
||||
setDownloadProgressText("CSV export 작업이 대기 중입니다.");
|
||||
return;
|
||||
}
|
||||
setDownloadProgressText("Excel 파일을 준비하고 있습니다.");
|
||||
await downloadAgedPipeExcel(
|
||||
appliedFilters,
|
||||
(job) => {
|
||||
if (job.status === "QUEUED") {
|
||||
setDownloadProgressText("Excel 다운로드 작업이 대기 중입니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.status === "RUNNING") {
|
||||
setDownloadProgressText(
|
||||
`CSV export 생성 중입니다. (${job.progressPercent ?? 0}%)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
if (job.status === "RUNNING") {
|
||||
setDownloadProgressText(
|
||||
`Excel 파일 생성 중입니다. (${job.progressPercent ?? 0}%)`,
|
||||
);
|
||||
}
|
||||
},
|
||||
total,
|
||||
);
|
||||
setDownloadProgressText("");
|
||||
} catch (error) {
|
||||
console.error("노후관로 목록 다운로드 중 오류:", error);
|
||||
@@ -185,7 +206,7 @@ export default function AgedPipePopupPage() {
|
||||
alert(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "노후관로 CSV 다운로드 중 오류가 발생했습니다.",
|
||||
: "노후관로 Excel 다운로드 중 오류가 발생했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -322,7 +343,7 @@ export default function AgedPipePopupPage() {
|
||||
total={total}
|
||||
page={currentPage}
|
||||
pageSize={AGED_PIPE_PAGE_SIZE}
|
||||
onPageChange={(page) => void handleSearch(page)}
|
||||
onPageChange={(page) => void loadRows(page, appliedFilters)}
|
||||
onRowClick={handleRowClick}
|
||||
rowId={(row, index) => String(row.fid ?? row.fcltMngNo ?? index)}
|
||||
actions={
|
||||
|
||||
@@ -14,26 +14,35 @@ type AgedPipeExportProgress = {
|
||||
status: "QUEUED" | "RUNNING" | "COMPLETED";
|
||||
};
|
||||
|
||||
/** 노후관로 CSV 다운로드를 요청한다. */
|
||||
export const downloadAgedPipeCsv = async (
|
||||
/** 노후관로 XLSX 다운로드를 요청한다. */
|
||||
export const downloadAgedPipeExcel = async (
|
||||
filters: AgedPipeFilters,
|
||||
onProgress?: (progress: AgedPipeExportProgress) => void,
|
||||
expectedTotal?: number,
|
||||
) => {
|
||||
const normalizedFilters = normalizeAgedPipeFilters(filters);
|
||||
|
||||
onProgress?.({ status: "RUNNING", progressPercent: 0 });
|
||||
|
||||
const { blob, fileName } = await postBlobData("v1/fclts/agedPipes/exportJobs/download", {
|
||||
elapsedYears: normalizedFilters.elapsedYears,
|
||||
ctpvCd: normalizedFilters.ctpvCd,
|
||||
sggCd: normalizedFilters.sggCd,
|
||||
format: "csv",
|
||||
columns: [...AGED_PIPE_EXCEL_FIELDS],
|
||||
});
|
||||
const { blob, fileName } = await postBlobData(
|
||||
"v1/fclts/agedPipes/exportJobs/download",
|
||||
{
|
||||
elapsedYears: normalizedFilters.elapsedYears,
|
||||
ctpvCd: normalizedFilters.ctpvCd,
|
||||
sggCd: normalizedFilters.sggCd,
|
||||
format: "xlsx",
|
||||
columns: [...AGED_PIPE_EXCEL_FIELDS],
|
||||
expectedTotal,
|
||||
},
|
||||
);
|
||||
|
||||
const resolvedFileName = fileName || "노후관로_현황.csv";
|
||||
const resolvedFileName = fileName || "노후관로_현황.xlsx";
|
||||
fileDownload(blob, resolvedFileName);
|
||||
onProgress?.({ status: "COMPLETED", fileName: resolvedFileName, progressPercent: 100 });
|
||||
onProgress?.({
|
||||
status: "COMPLETED",
|
||||
fileName: resolvedFileName,
|
||||
progressPercent: 100,
|
||||
});
|
||||
|
||||
return {
|
||||
status: "COMPLETED" as const,
|
||||
|
||||
+41
-18
@@ -11,15 +11,15 @@ import {
|
||||
AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
|
||||
buildAgedPipeStatusByCtpvHeadList,
|
||||
buildAgedPipeStatusYearOptions,
|
||||
getAgedPipeStatusByCtpvValidationMessage,
|
||||
normalizeAgedPipeStatusByCtpvResponse,
|
||||
normalizeAgedPipeStatusByCtpvFilters,
|
||||
toAgedPipeStatusByCtpvRows,
|
||||
validateAgedPipeStatusByCtpvFilters,
|
||||
type AgedPipeStatusByCtpvFilters,
|
||||
type AgedPipeStatusByCtpvResponse,
|
||||
type AgedPipeStatusByCtpvRow,
|
||||
} from "./agedPipeStatusByCtpvUtils";
|
||||
import { downloadAgedPipeStatusByCtpvCsv } from "./agedPipeStatusByCtpvExport";
|
||||
import { downloadAgedPipeStatusByCtpvExcel } from "./agedPipeStatusByCtpvExport";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
|
||||
@@ -34,6 +34,13 @@ export default function AgedPipeStatusByCtpvPopupPage() {
|
||||
elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
|
||||
}),
|
||||
);
|
||||
// 화면 목록을 마지막으로 조회한 조건이며 페이지 이동·Excel이 동일하게 사용한다.
|
||||
const [appliedFilters, setAppliedFilters] =
|
||||
useState<AgedPipeStatusByCtpvFilters>(() =>
|
||||
normalizeAgedPipeStatusByCtpvFilters({
|
||||
elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
|
||||
}),
|
||||
);
|
||||
// 서버 응답을 화면 그리드에 맞게 평탄화한 row 목록이다.
|
||||
const [rows, setRows] = useState<AgedPipeStatusByCtpvRow[]>([]);
|
||||
// 동적 컬럼 생성을 위한 기준년도 목록이다.
|
||||
@@ -80,6 +87,7 @@ export default function AgedPipeStatusByCtpvPopupPage() {
|
||||
setYears(Array.isArray(response?.years) ? response.years : []);
|
||||
setRows(toAgedPipeStatusByCtpvRows(response));
|
||||
setCurrentPage(1);
|
||||
setAppliedFilters(normalized);
|
||||
} catch (error) {
|
||||
console.error("시도별 노후관로현황 조회 중 오류:", error);
|
||||
setYears([]);
|
||||
@@ -92,26 +100,43 @@ export default function AgedPipeStatusByCtpvPopupPage() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRows(filters);
|
||||
}, [filters, loadRows]);
|
||||
const initialFilters = normalizeAgedPipeStatusByCtpvFilters({
|
||||
elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
|
||||
});
|
||||
void loadRows(initialFilters);
|
||||
}, [loadRows]);
|
||||
|
||||
const handleSearch = async () => {
|
||||
const normalized = normalizeAgedPipeStatusByCtpvFilters(filters);
|
||||
if (!validateAgedPipeStatusByCtpvFilters(normalized)) {
|
||||
alert(
|
||||
"기준년도는 최대 3년 범위 내에서 선택하고, 경과년도는 1~100 사이로 입력해주세요.",
|
||||
);
|
||||
const validationMessage = getAgedPipeStatusByCtpvValidationMessage(filters);
|
||||
if (validationMessage) {
|
||||
alert(validationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeAgedPipeStatusByCtpvFilters(filters);
|
||||
setFilters(normalized);
|
||||
await loadRows(normalized);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
const validationMessage = getAgedPipeStatusByCtpvValidationMessage(filters);
|
||||
if (validationMessage) {
|
||||
alert(validationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedDraft = normalizeAgedPipeStatusByCtpvFilters(filters);
|
||||
if (JSON.stringify(normalizedDraft) !== JSON.stringify(appliedFilters)) {
|
||||
alert("조회조건이 변경되었습니다. 조회 후 Excel을 다운로드해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setDownloadProgressText("시도별 노후관로현황 CSV를 준비하고 있습니다.");
|
||||
await downloadAgedPipeStatusByCtpvCsv(filters);
|
||||
setDownloadProgressText(
|
||||
"시도별 노후관로현황 Excel 파일을 준비하고 있습니다.",
|
||||
);
|
||||
await downloadAgedPipeStatusByCtpvExcel(appliedFilters);
|
||||
setDownloadProgressText("");
|
||||
} catch (error) {
|
||||
console.error("시도별 노후관로현황 다운로드 중 오류:", error);
|
||||
@@ -119,7 +144,7 @@ export default function AgedPipeStatusByCtpvPopupPage() {
|
||||
alert(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "시도별 노후관로현황 CSV 다운로드 중 오류가 발생했습니다.",
|
||||
: "시도별 노후관로현황 Excel 다운로드 중 오류가 발생했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -148,12 +173,10 @@ export default function AgedPipeStatusByCtpvPopupPage() {
|
||||
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl"
|
||||
value={filters.startYear}
|
||||
onChange={(event) =>
|
||||
setFilters((current) =>
|
||||
normalizeAgedPipeStatusByCtpvFilters({
|
||||
...current,
|
||||
startYear: Number(event.target.value),
|
||||
}),
|
||||
)
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
startYear: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
>
|
||||
{yearOptions.map((year) => (
|
||||
|
||||
+17
-10
@@ -3,23 +3,30 @@
|
||||
import { postBlobData } from "@/components/lib/api";
|
||||
import { fileDownload } from "@/components/lib/utils/fileDownload";
|
||||
import {
|
||||
getAgedPipeStatusByCtpvValidationMessage,
|
||||
normalizeAgedPipeStatusByCtpvFilters,
|
||||
type AgedPipeStatusByCtpvFilters,
|
||||
} from "./agedPipeStatusByCtpvUtils";
|
||||
|
||||
/** 시도별 노후관로현황 CSV 다운로드를 요청한다. */
|
||||
export const downloadAgedPipeStatusByCtpvCsv = async (
|
||||
/** 시도별 노후관로현황 XLSX 다운로드를 요청한다. */
|
||||
export const downloadAgedPipeStatusByCtpvExcel = async (
|
||||
filters: AgedPipeStatusByCtpvFilters,
|
||||
) => {
|
||||
const normalizedFilters = normalizeAgedPipeStatusByCtpvFilters(filters);
|
||||
const { blob, fileName } = await postBlobData("v1/swagePpl/aged-status-by-ctpv/exportJobs/download", {
|
||||
startYear: normalizedFilters.startYear,
|
||||
endYear: normalizedFilters.endYear,
|
||||
elapsedYears: normalizedFilters.elapsedYears,
|
||||
format: "csv",
|
||||
});
|
||||
const validationMessage = getAgedPipeStatusByCtpvValidationMessage(filters);
|
||||
if (validationMessage) throw new Error(validationMessage);
|
||||
|
||||
const resolvedFileName = fileName || "시도별_노후관로현황.csv";
|
||||
const normalizedFilters = normalizeAgedPipeStatusByCtpvFilters(filters);
|
||||
const { blob, fileName } = await postBlobData(
|
||||
"v1/swagePpl/aged-status-by-ctpv/exportJobs/download",
|
||||
{
|
||||
startYear: normalizedFilters.startYear,
|
||||
endYear: normalizedFilters.endYear,
|
||||
elapsedYears: normalizedFilters.elapsedYears,
|
||||
format: "xlsx",
|
||||
},
|
||||
);
|
||||
|
||||
const resolvedFileName = fileName || "시도별_노후관로현황.xlsx";
|
||||
fileDownload(blob, resolvedFileName);
|
||||
return resolvedFileName;
|
||||
};
|
||||
|
||||
+16
-8
@@ -87,9 +87,6 @@ export const normalizeAgedPipeStatusByCtpvFilters = (
|
||||
startYear = endYear;
|
||||
endYear = swap;
|
||||
}
|
||||
if (endYear - startYear + 1 > AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN) {
|
||||
endYear = startYear + (AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN - 1);
|
||||
}
|
||||
|
||||
return {
|
||||
startYear,
|
||||
@@ -98,18 +95,29 @@ export const normalizeAgedPipeStatusByCtpvFilters = (
|
||||
};
|
||||
};
|
||||
|
||||
export const validateAgedPipeStatusByCtpvFilters = (
|
||||
/** 입력한 기준년도·경과년도가 조회 가능한 범위인지 사용자 안내 문구로 반환한다. */
|
||||
export const getAgedPipeStatusByCtpvValidationMessage = (
|
||||
filters: AgedPipeStatusByCtpvFilters,
|
||||
) => {
|
||||
if (filters.startYear > filters.endYear) return false;
|
||||
if (filters.startYear > filters.endYear) {
|
||||
return "기준년도 시작은 종료보다 늦을 수 없습니다.";
|
||||
}
|
||||
if (
|
||||
filters.endYear - filters.startYear + 1 >
|
||||
AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN
|
||||
)
|
||||
return false;
|
||||
return filters.elapsedYears >= 1 && filters.elapsedYears <= 100;
|
||||
) {
|
||||
return `기준년도 조회 범위는 최대 ${AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN}년까지 가능합니다.`;
|
||||
}
|
||||
if (filters.elapsedYears < 1 || filters.elapsedYears > 100) {
|
||||
return "노후관로 기준은 1년부터 100년까지 입력할 수 있습니다.";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const validateAgedPipeStatusByCtpvFilters = (
|
||||
filters: AgedPipeStatusByCtpvFilters,
|
||||
) => getAgedPipeStatusByCtpvValidationMessage(filters) === null;
|
||||
|
||||
const formatMetric = (value: number) =>
|
||||
Number(value ?? 0).toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: 2,
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
TriangleAlert,
|
||||
Waves,
|
||||
} from "lucide-react";
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import { createPredictionAnalysisTimeRange } from "@/features/fldngPredcPrst/model/analysisTimeRange";
|
||||
import {
|
||||
windowPopupDefinitions,
|
||||
type WindowPopupKey,
|
||||
@@ -120,6 +122,32 @@ export default function AnalysisPopupMenu({
|
||||
const [activeKey, setActiveKey] = useState<
|
||||
WindowPopupKey | "infoSearch" | null
|
||||
>(initialActiveKey);
|
||||
const [seriousPredictionCount, setSeriousPredictionCount] = useState(0);
|
||||
|
||||
// 침수예측현황 팝업의 기본 분석시각 범위와 동일한 범위로 메뉴 건수를 조회한다.
|
||||
useEffect(() => {
|
||||
const range = createPredictionAnalysisTimeRange();
|
||||
const params = new URLSearchParams({
|
||||
analysisFrom: range.analysisFrom,
|
||||
analysisTo: range.analysisTo,
|
||||
});
|
||||
let cancelled = false;
|
||||
void getRawJson(
|
||||
`/sewerage-service/api/v1/fldng/predc/prst/count?${params.toString()}`,
|
||||
)
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
const payload = response?.data ?? response;
|
||||
const count = Number(payload);
|
||||
setSeriousPredictionCount(Number.isFinite(count) ? count : 0);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSeriousPredictionCount(0);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGisEditActive && activeKey === "gisEditPopup") {
|
||||
@@ -171,6 +199,10 @@ export default function AnalysisPopupMenu({
|
||||
item.key === "gisEditPopup"
|
||||
? isGisEditActive
|
||||
: activeKey === item.key;
|
||||
const label =
|
||||
item.key === "fldngPredcPrst"
|
||||
? `침수예측현황(${seriousPredictionCount}건)`
|
||||
: item.label;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -218,7 +250,7 @@ export default function AnalysisPopupMenu({
|
||||
}`}
|
||||
/>
|
||||
) : null}
|
||||
<span className="whitespace-nowrap">{item.label}</span>
|
||||
<span className="whitespace-nowrap">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -519,6 +519,15 @@ export const FldngWtlvMapCol2 = [
|
||||
{ id: 'rainTot', title: '총강우량(mm)' },
|
||||
{ id: 'rainStr', title: '강우강도(mm/hr)' },
|
||||
];
|
||||
export const FldngDpthMapCol = [
|
||||
{ id: 'fldng_yr', title: '침수년도' },
|
||||
{ id: 'ctpv_nm', title: '시도명' },
|
||||
{ id: 'sgg_nm', title: '시군구명' },
|
||||
{ id: 'fldng_dpth', title: '침수깊이(m)' },
|
||||
{ id: 'fldng_area', title: '침수면적(㎡)' },
|
||||
// { id: 'shape_area', title: '형상면적(㎡)' },
|
||||
// { id: 'shape_len', title: '형상길이' },
|
||||
];
|
||||
export const FldngWtlvMapExcelCol = {
|
||||
'fldngYr': '침수년도',
|
||||
'fldngDstrNm': '침수재해명',
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import type { ExmnMapFilters, ExmnMapJobType } from "./exmnMapTypes";
|
||||
import LayerSettingCard from "../layerSetting/LayerSettingCard";
|
||||
|
||||
type Props = {
|
||||
jobType: ExmnMapJobType;
|
||||
filters: ExmnMapFilters;
|
||||
onFiltersChange: (filters: ExmnMapFilters) => void;
|
||||
enabled?: boolean;
|
||||
onEnabledChange?: (enabled: boolean) => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
const GRADE_OPTIONS = ["", "1", "2", "3", "4", "5"];
|
||||
@@ -15,6 +19,9 @@ export default function ExmnLayerSettingPanel({
|
||||
jobType,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
enabled = true,
|
||||
onEnabledChange = () => {},
|
||||
onClose = () => {},
|
||||
}: Props) {
|
||||
const updateFilter = (key: keyof ExmnMapFilters, value: string) => {
|
||||
onFiltersChange({
|
||||
@@ -23,23 +30,25 @@ export default function ExmnLayerSettingPanel({
|
||||
});
|
||||
};
|
||||
const selectClass =
|
||||
"h-[24px] w-[54px] rounded-[4px] border border-input bg-background px-2 text-[1rem] text-foreground outline-none focus:border-primary";
|
||||
const labelClass = "shrink-0 text-[1.05rem] font-medium text-label";
|
||||
"h-[20px] w-[62px] rounded border border-input bg-background px-[1px] text-center text-[1.1rem] leading-none text-foreground outline-none focus:border-primary";
|
||||
const labelClass = "shrink-0 text-[1.15rem] font-medium text-label";
|
||||
const title = jobType === "PPL" ? "관로조사" : "맨홀조사";
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-[6px] border border-border bg-background px-2 py-[9px] shadow-blur">
|
||||
<div className="mb-2 flex items-center gap-1">
|
||||
{/* 관로/맨홀 조사유형을 구분하는 지도 범례 의미색이므로 고정색을 유지한다. */}
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${jobType === "PPL" ? "bg-[#facc15]" : "bg-[#8b5cf6]"}`}
|
||||
/>
|
||||
<span className="text-[1.1rem] font-semibold text-label">
|
||||
{jobType === "PPL" ? "관로조사" : "맨홀조사"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex min-h-[25px] items-center justify-between gap-2">
|
||||
<span className={labelClass}>사업차수</span>
|
||||
<LayerSettingCard
|
||||
title={title}
|
||||
enabled={enabled}
|
||||
onEnabledChange={onEnabledChange}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-[1px]">
|
||||
<label className="flex min-h-[24px] items-center justify-between ">
|
||||
<span className={`flex items-center gap-1 ${labelClass}`}>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${jobType === "PPL" ? "bg-[#facc15]" : "bg-[#8b5cf6]"}`}
|
||||
/>
|
||||
사업차수
|
||||
</span>
|
||||
<input
|
||||
className={selectClass}
|
||||
type="text"
|
||||
@@ -50,7 +59,7 @@ export default function ExmnLayerSettingPanel({
|
||||
onChange={(event) => updateFilter("bizCycl", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex min-h-[25px] items-center justify-between gap-2">
|
||||
<label className="flex min-h-[24px] items-center justify-between ">
|
||||
<span className={labelClass}>구조적 상태등급</span>
|
||||
<select
|
||||
className={selectClass}
|
||||
@@ -66,7 +75,7 @@ export default function ExmnLayerSettingPanel({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex min-h-[25px] items-center justify-between gap-2">
|
||||
<label className="flex min-h-[24px] items-center justify-between ">
|
||||
<span className={labelClass}>운영적 상태등급</span>
|
||||
<select
|
||||
className={selectClass}
|
||||
@@ -83,6 +92,6 @@ export default function ExmnLayerSettingPanel({
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</LayerSettingCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { SelectBox } from "@common/business_components/ui";
|
||||
import {
|
||||
FLOOD_HISTORY_MIN_YEAR,
|
||||
normalizeFloodHistoryYearRange,
|
||||
type FloodHistoryYearRange,
|
||||
} from "./fldngHstryTypes";
|
||||
|
||||
type Props = {
|
||||
yearRange: FloodHistoryYearRange;
|
||||
enabled: boolean;
|
||||
onYearRangeChange: (yearRange: FloodHistoryYearRange) => void;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/** 침수흔적위선·심선에 공통 적용되는 즉시 반영 침수년도 설정 카드다. */
|
||||
export default function FldngHstryLayerSettingPanel({
|
||||
yearRange,
|
||||
enabled,
|
||||
onYearRangeChange,
|
||||
onEnabledChange,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const normalizedRange = normalizeFloodHistoryYearRange(yearRange);
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearOptions = Array.from(
|
||||
{ length: currentYear - FLOOD_HISTORY_MIN_YEAR + 1 },
|
||||
(_, index) => String(currentYear - index),
|
||||
);
|
||||
const startYearOptions = yearOptions
|
||||
.filter((year) => Number(year) <= Number(normalizedRange.endYear))
|
||||
.map((year) => ({ val: year, text: year }));
|
||||
const endYearOptions = yearOptions
|
||||
.filter((year) => Number(year) >= Number(normalizedRange.startYear))
|
||||
.map((year) => ({ val: year, text: year }));
|
||||
const yearSelectClass =
|
||||
"h-[20px] w-[62px] !rounded border border-input bg-background px-[1px] text-center text-[1.1rem] leading-none text-foreground";
|
||||
|
||||
const changeYear = (key: keyof FloodHistoryYearRange, value: string) => {
|
||||
onYearRangeChange(
|
||||
normalizeFloodHistoryYearRange({
|
||||
...normalizedRange,
|
||||
[key]: value,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-visible rounded-[6px] border border-border bg-background shadow-blur">
|
||||
<div className="flex min-h-[28px] items-center justify-between border-b border-border px-2">
|
||||
<span className="text-[1.15rem] font-semibold text-label">
|
||||
침수흔적도
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
aria-label="침수년도 필터 적용 여부"
|
||||
title={enabled ? "침수년도 필터 적용 중" : "침수년도 필터 미적용"}
|
||||
className={`relative h-[16px] w-[28px] rounded-full border transition-colors ${enabled ? "border-primary bg-primary" : "border-[#94a3b8] bg-[#cbd5e1]"}`}
|
||||
onClick={() => onEnabledChange(!enabled)}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1/2 h-[12px] w-[12px] -translate-y-1/2 rounded-full bg-white shadow-sm transition-[left] ${enabled ? "left-[14px]" : "left-[2px]"}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="침수년도 설정 닫기"
|
||||
title="닫기"
|
||||
className="grid h-[20px] w-[20px] place-items-center bg-transparent text-[1.3rem] leading-none text-muted-foreground hover:text-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-[30px] items-center gap-1.5 px-2 py-1 text-[1.15rem] font-medium text-label">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
<span className="shrink-0">침수년도</span>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
<div className="w-[62px]">
|
||||
<SelectBox
|
||||
className="w-full"
|
||||
selectClass={yearSelectClass}
|
||||
label=""
|
||||
selectArray={startYearOptions}
|
||||
value={normalizedRange.startYear}
|
||||
onSelectValue={(value) => changeYear("startYear", String(value))}
|
||||
selectContentClass="z-[101]"
|
||||
labelClass="hidden"
|
||||
/>
|
||||
</div>
|
||||
<span className="px-[1px] text-[0.95rem] font-normal text-muted-foreground">
|
||||
~
|
||||
</span>
|
||||
<div className="w-[62px]">
|
||||
<SelectBox
|
||||
className="w-full"
|
||||
selectClass={yearSelectClass}
|
||||
label=""
|
||||
selectArray={endYearOptions}
|
||||
value={normalizedRange.endYear}
|
||||
onSelectValue={(value) => changeYear("endYear", String(value))}
|
||||
selectContentClass="z-[101]"
|
||||
labelClass="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { getRawJson } from "@/components/lib/api";
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
import useStore from "@/store/useStore";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
@@ -51,8 +52,7 @@ const resolveFldngHstryMapFeatureFid = (row: FldngHstryTableRow) => {
|
||||
if (fid.startsWith(`${FLOOD_HISTORY_LAYER_TYPE}.`)) return fid;
|
||||
if (fid) return `${FLOOD_HISTORY_LAYER_TYPE}.${fid}`;
|
||||
|
||||
const mapFid = String(row.fldngMapFid ?? "").trim();
|
||||
return mapFid || "";
|
||||
return "";
|
||||
};
|
||||
|
||||
const buildDownloadRows = (rows: FldngHstryTableRow[]) =>
|
||||
@@ -82,8 +82,14 @@ const resolveRegionNames = (
|
||||
sggOptions: Array<{ text: string; val: string | number }>,
|
||||
): FldngHstryTableRow => ({
|
||||
...row,
|
||||
ctpvNm: resolveOptionText(row.ctpvCd ?? "", ctpvOptions, 2) || row.ctpvNm,
|
||||
sggNm: resolveOptionText(row.sggCd ?? "", sggOptions, 5) || row.sggNm,
|
||||
ctpvNm:
|
||||
resolveOptionText(row.ctpvCd ?? "", ctpvOptions, 2) ||
|
||||
row.ctpvNm ||
|
||||
String((row as any).ctpv_nm ?? row.ctpvCd ?? "").trim(),
|
||||
sggNm:
|
||||
resolveOptionText(row.sggCd ?? "", sggOptions, 5) ||
|
||||
row.sggNm ||
|
||||
String((row as any).sgg_nm ?? row.sggCd ?? "").trim(),
|
||||
});
|
||||
|
||||
export default function FldngHstryPopupPage() {
|
||||
@@ -116,6 +122,7 @@ export default function FldngHstryPopupPage() {
|
||||
|
||||
const fldngYearOptions = useMemo(() => buildFldngHstryYearOptions(), []);
|
||||
|
||||
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
filters.ctpvCd === "0"
|
||||
@@ -147,8 +154,14 @@ export default function FldngHstryPopupPage() {
|
||||
const hydrated = attachFldngHstryGeometry(item);
|
||||
return {
|
||||
...hydrated,
|
||||
ctpvNm: resolveOptionText(hydrated.ctpvCd ?? "", ctpv as any, 2),
|
||||
sggNm: resolveOptionText(hydrated.sggCd ?? "", sgg as any, 5),
|
||||
ctpvNm:
|
||||
resolveOptionText(hydrated.ctpvCd ?? "", ctpv as any, 2) ||
|
||||
String((hydrated as any).ctpvNm ?? (hydrated as any).ctpv_nm ?? "").trim() ||
|
||||
String(hydrated.ctpvCd ?? "").trim(),
|
||||
sggNm:
|
||||
resolveOptionText(hydrated.sggCd ?? "", sgg as any, 5) ||
|
||||
String((hydrated as any).sggNm ?? (hydrated as any).sgg_nm ?? "").trim() ||
|
||||
String(hydrated.sggCd ?? "").trim(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -246,13 +259,15 @@ export default function FldngHstryPopupPage() {
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetchRows(
|
||||
1,
|
||||
appliedFilters,
|
||||
Math.max(total, FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE),
|
||||
);
|
||||
const response = await fetchAllPages({
|
||||
batchSize: FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE,
|
||||
fetchPage: (page, size) =>
|
||||
fetchRows(page + 1, appliedFilters, size),
|
||||
getItems: (page) => page.rows,
|
||||
getTotal: (page) => page.total,
|
||||
});
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: buildDownloadRows(response.rows) as unknown as JSON[],
|
||||
data: buildDownloadRows(response.items) as unknown as JSON[],
|
||||
fileName: `침수발생이력_${new Date().toISOString().slice(0, 10)}.xlsx`,
|
||||
sheetNameArray: ["침수발생이력"],
|
||||
headerRange: "A1:Q1",
|
||||
@@ -280,7 +295,7 @@ export default function FldngHstryPopupPage() {
|
||||
시도
|
||||
</span>
|
||||
<select
|
||||
className="h-[3.2rem] w-[16rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
|
||||
className="h-[3.2rem] w-[12rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
|
||||
value={filters.ctpvCd}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -327,7 +342,7 @@ export default function FldngHstryPopupPage() {
|
||||
침수발생년도
|
||||
</span>
|
||||
<select
|
||||
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
|
||||
className="h-[3.2rem] w-[8rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
|
||||
value={filters.floodYear}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -345,6 +360,24 @@ export default function FldngHstryPopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">강우시작일</span>
|
||||
<Input
|
||||
className="w-[11rem]"
|
||||
type="date"
|
||||
value={filters.rainStartDate}
|
||||
min={filters.floodYear ? `${filters.floodYear}-01-01` : undefined}
|
||||
max={filters.floodYear ? `${filters.floodYear}-12-31` : undefined}
|
||||
disabled={!filters.floodYear}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, rainStartDate: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">침수지구명</span>
|
||||
<Input type="text" value={filters.districtName} onChange={(event) => setFilters((current) => ({ ...current, districtName: event.target.value }))} 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">
|
||||
침수재해명
|
||||
|
||||
@@ -5,18 +5,65 @@ import type { UiHeadProps } from "@common/business_components/ui";
|
||||
export const FLOOD_HISTORY_POPUP_KEY = "fldngHstryPopup";
|
||||
export const FLDNG_HSTRY_ROW_SELECT_MESSAGE = "SN_FLOOD_HISTORY_ROW_SELECT";
|
||||
export const FLOOD_HISTORY_LAYER_TYPE = "tb_ms_fldng_wtlv_map";
|
||||
export const FLOOD_HISTORY_DEPTH_LAYER_TYPE = "tb_ms_fldng_dpth_map";
|
||||
export const FLOOD_HISTORY_PREVIEW_LAYER_TYPE = "tb_sn_flood_trace_mock";
|
||||
export const FLOOD_HISTORY_LAYER_NAME = "침수흔적도";
|
||||
export const FLOOD_HISTORY_LAYER_NAME = "침수흔적위선";
|
||||
export const FLOOD_HISTORY_DEPTH_LAYER_NAME = "침수흔적심선";
|
||||
export const FLOOD_HISTORY_ROOT_NAME = "침수흔적도";
|
||||
export const FLOOD_HISTORY_AREA_LAYER_ID = "SN_FLOOD_HISTORY_AREA";
|
||||
export const FLOOD_HISTORY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH";
|
||||
export const FLDNG_HSTRY_PAGE_SIZE = 10;
|
||||
export const FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE = 2000;
|
||||
export const FLOOD_HISTORY_MIN_YEAR = 2002;
|
||||
|
||||
/** 지도 침수흔적위선·심선이 공통으로 사용하는 침수년도 범위다. */
|
||||
export type FloodHistoryYearRange = {
|
||||
startYear: string;
|
||||
endYear: string;
|
||||
};
|
||||
|
||||
/** 침수년도 설정의 초기 범위는 데이터 최소연도부터 현재년도까지다. */
|
||||
export const buildFloodHistoryDefaultYearRange = (
|
||||
currentYear = new Date().getFullYear(),
|
||||
): FloodHistoryYearRange => ({
|
||||
startYear: String(FLOOD_HISTORY_MIN_YEAR),
|
||||
endYear: String(currentYear),
|
||||
});
|
||||
|
||||
/** 연도 설정값을 4자리 숫자로 정규화하고 유효하지 않으면 기본 범위를 사용한다. */
|
||||
export const normalizeFloodHistoryYearRange = (
|
||||
range?: Partial<FloodHistoryYearRange> | null,
|
||||
fallback: FloodHistoryYearRange = buildFloodHistoryDefaultYearRange(),
|
||||
): FloodHistoryYearRange => {
|
||||
const normalizeYear = (value: unknown) =>
|
||||
String(value ?? "")
|
||||
.replace(/[^0-9]/g, "")
|
||||
.slice(0, 4);
|
||||
const startYear = normalizeYear(range?.startYear);
|
||||
const endYear = normalizeYear(range?.endYear);
|
||||
return {
|
||||
startYear: /^\d{4}$/.test(startYear) ? startYear : fallback.startYear,
|
||||
endYear: /^\d{4}$/.test(endYear) ? endYear : fallback.endYear,
|
||||
};
|
||||
};
|
||||
|
||||
/** 위선·심선 WFS에 동일하게 결합할 침수년도 CQL 조건을 생성한다. */
|
||||
export const buildFloodHistoryYearCql = (
|
||||
range: FloodHistoryYearRange,
|
||||
enabled: boolean,
|
||||
) => {
|
||||
if (!enabled) return "";
|
||||
const normalized = normalizeFloodHistoryYearRange(range);
|
||||
return `(fldng_yr >= '${normalized.startYear}' AND fldng_yr <= '${normalized.endYear}')`;
|
||||
};
|
||||
|
||||
export type FldngHstryFilters = {
|
||||
ctpvCd: string;
|
||||
sggCd: string;
|
||||
floodYear: string;
|
||||
disasterName: string;
|
||||
rainStartDate: string;
|
||||
districtName: string;
|
||||
};
|
||||
|
||||
export type FldngHstryDepthZone = {
|
||||
@@ -108,6 +155,8 @@ export const FLDNG_HSTRY_DEFAULT_FILTERS: FldngHstryFilters = {
|
||||
sggCd: "0",
|
||||
floodYear: "",
|
||||
disasterName: "",
|
||||
rainStartDate: "",
|
||||
districtName: "",
|
||||
};
|
||||
|
||||
export const FLDNG_HSTRY_DEPTH_LEGEND_ITEMS = [
|
||||
@@ -119,13 +168,13 @@ export const FLDNG_HSTRY_DEPTH_LEGEND_ITEMS = [
|
||||
] as const;
|
||||
|
||||
export const FLDNG_HSTRY_LIST_HEADLIST: UiHeadProps[] = [
|
||||
{ id: "rowNo", title: "순번", widthPercent: 5 },
|
||||
{ id: "floodYearDisplay", title: "침수\n년도", widthPercent: 6 },
|
||||
{ id: "rowNo", title: "순번", widthPercent: 3 },
|
||||
{ id: "floodYearDisplay", title: "침수\n년도", widthPercent: 4 },
|
||||
{ id: "ctpvNm", title: "시도", widthPercent: 7 },
|
||||
{ id: "sggNm", title: "시군구", widthPercent: 8 },
|
||||
{ id: "disasterName", title: "침수재해명", widthPercent: 14 },
|
||||
{ id: "rainBgngTmDisplay", title: "강우\n시작일시", widthPercent: 10 },
|
||||
{ id: "rainEndTmDisplay", title: "강우\n종료일시", widthPercent: 10 },
|
||||
{ id: "disasterName", title: "침수재해명", widthPercent: 20 },
|
||||
{ id: "rainBgngTmDisplay", title: "강우\n시작일시", widthPercent: 9 },
|
||||
{ id: "rainEndTmDisplay", title: "강우\n종료일시", widthPercent: 9 },
|
||||
{ id: "rainTotDisplay", title: "총강우량(mm)", widthPercent: 8 },
|
||||
{ id: "rainStrDisplay", title: "강우강도(mm/hr)", widthPercent: 8 },
|
||||
{ id: "floodHourDisplay", title: "침수시간", widthPercent: 7 },
|
||||
@@ -350,6 +399,8 @@ export const normalizeFldngHstryFilters = (
|
||||
.replace(/[^0-9]/g, "")
|
||||
.slice(0, 4),
|
||||
disasterName: String(filters.disasterName ?? "").trim(),
|
||||
rainStartDate: String(filters.rainStartDate ?? "").trim(),
|
||||
districtName: String(filters.districtName ?? "").trim(),
|
||||
});
|
||||
|
||||
export const buildFldngHstryYearOptions = () => {
|
||||
@@ -375,6 +426,10 @@ export const buildFldngHstryListQueryString = (
|
||||
if (normalized.floodYear) params.set("fldngYr", normalized.floodYear);
|
||||
if (normalized.disasterName)
|
||||
params.set("fldngDstrNm", normalized.disasterName);
|
||||
if (normalized.rainStartDate)
|
||||
params.set("rainBgngTm", normalized.rainStartDate);
|
||||
if (normalized.districtName)
|
||||
params.set("fldngDstrctNm", normalized.districtName);
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
@@ -485,11 +540,35 @@ export const isFldngHstryNode = (node: any) => {
|
||||
const layerType = resolveFldngHstryLayerType(node);
|
||||
return (
|
||||
name === FLOOD_HISTORY_LAYER_NAME ||
|
||||
name === FLOOD_HISTORY_DEPTH_LAYER_NAME ||
|
||||
layerType === FLOOD_HISTORY_LAYER_TYPE ||
|
||||
layerType === FLOOD_HISTORY_DEPTH_LAYER_TYPE ||
|
||||
layerType === FLOOD_HISTORY_PREVIEW_LAYER_TYPE
|
||||
);
|
||||
};
|
||||
|
||||
/** 침수흔적위선 체크 상태를 심선과 분리해 판별한다. */
|
||||
export const isFloodHistoryWaterLevelNode = (node: any) => {
|
||||
const name = resolveFldngHstryNodeName(node);
|
||||
const layerType = resolveFldngHstryLayerType(node);
|
||||
return (
|
||||
name === FLOOD_HISTORY_LAYER_NAME || layerType === FLOOD_HISTORY_LAYER_TYPE
|
||||
);
|
||||
};
|
||||
|
||||
/** 침수흔적심선 체크 상태를 위선과 분리해 판별한다. */
|
||||
export const isFloodHistoryDepthNode = (node: any) => {
|
||||
const name = resolveFldngHstryNodeName(node);
|
||||
const layerType = resolveFldngHstryLayerType(node);
|
||||
return (
|
||||
name === FLOOD_HISTORY_DEPTH_LAYER_NAME ||
|
||||
layerType === FLOOD_HISTORY_DEPTH_LAYER_TYPE
|
||||
);
|
||||
};
|
||||
|
||||
export const isFldngHstryRootNode = (node: any) =>
|
||||
resolveFldngHstryNodeName(node) === FLOOD_HISTORY_ROOT_NAME;
|
||||
|
||||
export const isFloodHistoryNode = isFldngHstryNode;
|
||||
export const FLOOD_HISTORY_ROW_SELECT_MESSAGE = FLDNG_HSTRY_ROW_SELECT_MESSAGE;
|
||||
export const FLOOD_HISTORY_PAGE_SIZE = FLDNG_HSTRY_PAGE_SIZE;
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { UiHeadProps } from "@common/business_components/ui";
|
||||
export const FLOOD_DNGR_MAP_POPUP_KEY = "floodDngrMapPopup";
|
||||
export const FLOOD_DNGR_MAP_LAYER_TYPE = "tb_ms_flood_dngr_map";
|
||||
export const FLOOD_DNGR_MAP_LAYER_NAME = "도시침수지도(정보제공포털)";
|
||||
export const FLOOD_DNGR_MAP_PAGE_SIZE = 20;
|
||||
export const FLOOD_DNGR_MAP_PAGE_SIZE = 10;
|
||||
|
||||
export type FloodDngrMapFilters = {
|
||||
ctpvCd: string;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import type { GisEditSessionSnapshot, GisEditTool } from "./gisEditTypes";
|
||||
import { useGisEditOperationLabels } from "./useGisEditCommonCodes";
|
||||
|
||||
type Props = GisEditSessionSnapshot & {
|
||||
onRemoveFocusedChange: () => void;
|
||||
@@ -14,13 +15,6 @@ type Props = GisEditSessionSnapshot & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const TOOL_LABELS: Record<GisEditTool, string> = {
|
||||
ADD: "추가",
|
||||
MOVE: "이동",
|
||||
VERTEX_EDIT: "꼭지점편집",
|
||||
DELETE: "삭제",
|
||||
};
|
||||
|
||||
const buildChangeItemClassName = (isFocused: boolean) =>
|
||||
`block w-full rounded-md border px-3 py-2.5 text-left text-[1.1rem] transition ${
|
||||
isFocused
|
||||
@@ -28,8 +22,13 @@ const buildChangeItemClassName = (isFocused: boolean) =>
|
||||
: "border-border bg-background hover:border-primary hover:bg-background"
|
||||
}`;
|
||||
|
||||
const getChangeLabel = (change: GisEditSessionSnapshot["changes"][number]) =>
|
||||
change.attributeOnly ? "상세정보수정" : TOOL_LABELS[change.editType];
|
||||
const getChangeLabel = (
|
||||
change: GisEditSessionSnapshot["changes"][number],
|
||||
operationLabels: Record<GisEditTool, string>,
|
||||
) =>
|
||||
change.attributeOnly
|
||||
? "상세정보수정"
|
||||
: operationLabels[change.editType];
|
||||
|
||||
const getChangeSummary = (
|
||||
change: GisEditSessionSnapshot["changes"][number],
|
||||
@@ -45,12 +44,14 @@ const getChangeSummary = (
|
||||
type ChangeListProps = {
|
||||
changes: GisEditSessionSnapshot["changes"];
|
||||
focusedChangeId: string | null;
|
||||
operationLabels: Record<GisEditTool, string>;
|
||||
onFocusChange: (changeId: string) => void;
|
||||
};
|
||||
|
||||
const ChangeList = ({
|
||||
changes,
|
||||
focusedChangeId,
|
||||
operationLabels,
|
||||
onFocusChange,
|
||||
}: ChangeListProps) => (
|
||||
<>
|
||||
@@ -65,7 +66,8 @@ const ChangeList = ({
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="shrink-0 whitespace-nowrap text-[1.1rem] font-semibold leading-4 text-label">
|
||||
{index + 1}. {getChangeLabel(change)} / {change.objectLabel}
|
||||
{index + 1}. {getChangeLabel(change, operationLabels)} /{" "}
|
||||
{change.objectLabel}
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{change.basicInfoSavedAt && focusedChangeId !== change.changeId ? (
|
||||
@@ -103,6 +105,8 @@ export default function GisEditSessionPanel({
|
||||
variant = "inline",
|
||||
className = "",
|
||||
}: Props) {
|
||||
// 인라인·윈도우 팝업 세션 모두 동일한 CMS 공통코드 라벨을 공유한다.
|
||||
const operationLabels = useGisEditOperationLabels();
|
||||
const isCompactInline = variant === "inline" && changes.length === 0;
|
||||
|
||||
return (
|
||||
@@ -125,7 +129,7 @@ export default function GisEditSessionPanel({
|
||||
{selectedLayerName || "레이어 미선택"} ·{" "}
|
||||
{isEditMode
|
||||
? activeTool
|
||||
? TOOL_LABELS[activeTool]
|
||||
? operationLabels[activeTool]
|
||||
: "대기"
|
||||
: "편집 모드 종료"}
|
||||
</div>
|
||||
@@ -190,6 +194,7 @@ export default function GisEditSessionPanel({
|
||||
<ChangeList
|
||||
changes={changes}
|
||||
focusedChangeId={focusedChangeId}
|
||||
operationLabels={operationLabels}
|
||||
onFocusChange={onFocusChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Check, Move, PenTool, Plus, Trash2, X } from "lucide-react";
|
||||
import { Button } from "@common/components/ui";
|
||||
|
||||
import type { GisEditTool } from "./gisEditTypes";
|
||||
import { useGisEditOperationLabels } from "./useGisEditCommonCodes";
|
||||
|
||||
type Props = {
|
||||
isEditMode: boolean;
|
||||
@@ -22,13 +23,12 @@ type Props = {
|
||||
|
||||
const TOOL_BUTTONS: Array<{
|
||||
tool: GisEditTool;
|
||||
label: string;
|
||||
icon: typeof Plus;
|
||||
}> = [
|
||||
{ tool: "ADD", label: "추가", icon: Plus },
|
||||
{ tool: "MOVE", label: "이동", icon: Move },
|
||||
{ tool: "VERTEX_EDIT", label: "꼭지점편집", icon: PenTool },
|
||||
{ tool: "DELETE", label: "삭제", icon: Trash2 },
|
||||
{ tool: "ADD", icon: Plus },
|
||||
{ tool: "MOVE", icon: Move },
|
||||
{ tool: "VERTEX_EDIT", icon: PenTool },
|
||||
{ tool: "DELETE", icon: Trash2 },
|
||||
];
|
||||
|
||||
export default function GisEditToolbar({
|
||||
@@ -45,6 +45,9 @@ export default function GisEditToolbar({
|
||||
onSelectTool,
|
||||
onSave,
|
||||
}: Props) {
|
||||
// 버튼의 업무 코드값은 그대로 유지하고 CMS 공통코드명만 화면 라벨로 사용한다.
|
||||
const operationLabels = useGisEditOperationLabels();
|
||||
|
||||
if (!isEditMode) return null;
|
||||
|
||||
return (
|
||||
@@ -67,10 +70,11 @@ export default function GisEditToolbar({
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-3">
|
||||
<div className="flex items-center gap-1 rounded-md bg-container p-1">
|
||||
{TOOL_BUTTONS.map(({ tool, label, icon: Icon }) => {
|
||||
{TOOL_BUTTONS.map(({ tool, icon: Icon }) => {
|
||||
const isActive = activeTool === tool;
|
||||
const isToolEnabled =
|
||||
canUseTools && allowedTools.includes(tool);
|
||||
const label = operationLabels[tool];
|
||||
if (tool === "VERTEX_EDIT") {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getCommonCodes } from "@/components/lib/common/apiHelpers";
|
||||
|
||||
import type { GisEditTool } from "./gisEditTypes";
|
||||
|
||||
const GIS_EDIT_OPERATION_UP_CD = "GISEDITOPRT";
|
||||
|
||||
/** CMS 조회 실패 시 기존 GIS 편집 화면 동작과 문구를 유지하기 위한 최소 fallback이다. */
|
||||
export const DEFAULT_GIS_EDIT_OPERATION_LABELS: Record<GisEditTool, string> = {
|
||||
ADD: "추가",
|
||||
MOVE: "이동",
|
||||
VERTEX_EDIT: "꼭지점편집",
|
||||
DELETE: "삭제",
|
||||
};
|
||||
|
||||
let cachedOperationLabels: Record<GisEditTool, string> | null = null;
|
||||
let operationLabelsPromise: Promise<Record<GisEditTool, string>> | null = null;
|
||||
|
||||
const isGisEditTool = (value: string): value is GisEditTool =>
|
||||
value === "ADD" ||
|
||||
value === "MOVE" ||
|
||||
value === "VERTEX_EDIT" ||
|
||||
value === "DELETE";
|
||||
|
||||
/** 같은 브라우저 세션에서는 GIS 편집작업코드를 한 번만 조회해 툴바와 세션이 공유한다. */
|
||||
const loadGisEditOperationLabels = async () => {
|
||||
if (cachedOperationLabels) return cachedOperationLabels;
|
||||
|
||||
if (!operationLabelsPromise) {
|
||||
operationLabelsPromise = getCommonCodes(GIS_EDIT_OPERATION_UP_CD)
|
||||
.then((codes) => {
|
||||
const labels = { ...DEFAULT_GIS_EDIT_OPERATION_LABELS };
|
||||
codes.forEach((code) => {
|
||||
if (isGisEditTool(code.lwpoCd) && code.lwpoCdNm) {
|
||||
labels[code.lwpoCd] = code.lwpoCdNm;
|
||||
}
|
||||
});
|
||||
cachedOperationLabels = labels;
|
||||
return labels;
|
||||
})
|
||||
.finally(() => {
|
||||
operationLabelsPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
return operationLabelsPromise;
|
||||
};
|
||||
|
||||
/** GIS 편집 도구 코드값은 유지하고 사용자 표시명만 CMS 공통코드로 교체한다. */
|
||||
export const useGisEditOperationLabels = () => {
|
||||
const [operationLabels, setOperationLabels] = useState<
|
||||
Record<GisEditTool, string>
|
||||
>(cachedOperationLabels ?? DEFAULT_GIS_EDIT_OPERATION_LABELS);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
loadGisEditOperationLabels()
|
||||
.then((labels) => {
|
||||
if (!cancelled) setOperationLabels(labels);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("GIS 편집작업 공통코드 조회에 실패했습니다.", error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return operationLabels;
|
||||
};
|
||||
@@ -31,6 +31,7 @@ import { useAlertStore } from "@/shared/lib";
|
||||
import { useUserStore } from "@/shared/lib/use-user-store";
|
||||
import GisUploadPreviewMap from "./GisUploadPreviewMap";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import { useGisManagementCommonCodes } from "./useGisManagementCommonCodes";
|
||||
|
||||
type UploadFacilityType = {
|
||||
facilityTypeCode: string;
|
||||
@@ -108,6 +109,8 @@ type DownloadJob = {
|
||||
retryAfterMillis?: number | null;
|
||||
};
|
||||
|
||||
type DownloadJobStatusListener = (job: DownloadJob) => void;
|
||||
|
||||
const SCREEN_ID = "SN_MN010302";
|
||||
const FACILITY_TYPE_CODE = "PPL";
|
||||
const LAYER_ID = "tb_ms_swage_ppl";
|
||||
@@ -212,12 +215,16 @@ const startDownloadJob = async (url: string): Promise<DownloadJob> => {
|
||||
return job;
|
||||
};
|
||||
|
||||
const waitForDownloadJob = async (jobId: string): Promise<DownloadJob> => {
|
||||
const waitForDownloadJob = async (
|
||||
jobId: string,
|
||||
onStatus?: DownloadJobStatusListener,
|
||||
): Promise<DownloadJob> => {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < DOWNLOAD_JOB_MAX_WAIT_MS) {
|
||||
const job = (await getData(
|
||||
`v1/map/gisDownloadJobs/${encodeURIComponent(jobId)}`,
|
||||
)) as DownloadJob;
|
||||
onStatus?.(job);
|
||||
if (job?.status === "READY") return job;
|
||||
if (job?.status === "FAILED") {
|
||||
throw new Error(
|
||||
@@ -283,6 +290,12 @@ const postRefreshMessageToOpener = (response: GisUploadResponse) => {
|
||||
export default function GisManagementPopupPage() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
// GIS 관리 코드값은 기존 분기에 사용하고 CMS 공통코드명은 화면 안내에만 사용한다.
|
||||
const {
|
||||
uploadStatusLabels,
|
||||
downloadStatusLabels,
|
||||
facilityTypeLabels,
|
||||
} = useGisManagementCommonCodes();
|
||||
// 파일 input은 브라우저 보안 정책상 value 직접 제어가 제한돼 ref로 선택/초기화한다.
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -344,6 +357,8 @@ export default function GisManagementPopupPage() {
|
||||
}, [mngInstOptions, selectedMngInstCd]);
|
||||
|
||||
const allowedExtensionText = facility.allowedExtensions.join(", ");
|
||||
const facilityTypeDisplayName =
|
||||
facilityTypeLabels[FACILITY_TYPE_CODE] || facility.facilityTypeName;
|
||||
const latestDownloadId = downloadIdsByScope[selectedScopeKey] ?? "";
|
||||
const isManagementSelected = Boolean(selectedMngInstCd);
|
||||
|
||||
@@ -399,43 +414,67 @@ export default function GisManagementPopupPage() {
|
||||
|
||||
const initialize = async () => {
|
||||
setIsLoading(true);
|
||||
// 시설유형과 관리기관은 서로 독립 계약이므로 한쪽 실패가 다른 콤보박스까지 비우지 않게 분리한다.
|
||||
const loadFacilityType = async () => {
|
||||
try {
|
||||
const facilityTypes = await getData(
|
||||
`v1/map/gisUploadFacilityTypes?screenId=${SCREEN_ID}`,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
const pplFacility = Array.isArray(facilityTypes)
|
||||
? facilityTypes.find(
|
||||
(item) => item?.facilityTypeCode === FACILITY_TYPE_CODE,
|
||||
)
|
||||
: null;
|
||||
if (pplFacility) {
|
||||
setFacility({
|
||||
...DEFAULT_FACILITY,
|
||||
...pplFacility,
|
||||
allowedExtensions: Array.isArray(pplFacility.allowedExtensions)
|
||||
? pplFacility.allowedExtensions
|
||||
: DEFAULT_FACILITY.allowedExtensions,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("GIS 관리 시설유형 조회 실패:", error);
|
||||
if (mounted) {
|
||||
setNotice(
|
||||
"GIS 시설정보를 불러오지 못했습니다. 기본 하수관로 설정으로 표시합니다.",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 관리기관 목록은 시설유형 조회 결과와 무관하게 즉시 화면 상태에 반영한다.
|
||||
const loadManagementOptions = async () => {
|
||||
try {
|
||||
const managementOptions = await getData("v1/fcltppl/mngInsts");
|
||||
if (!mounted) return;
|
||||
|
||||
const sortedOptions = Array.isArray(managementOptions)
|
||||
? [...managementOptions].sort((a, b) =>
|
||||
String(a?.lwrInstNm ?? a?.mngInstCd ?? "").localeCompare(
|
||||
String(b?.lwrInstNm ?? b?.mngInstCd ?? ""),
|
||||
"ko",
|
||||
),
|
||||
)
|
||||
: [];
|
||||
setMngInstOptions(sortedOptions);
|
||||
if (sortedOptions.length === 0) {
|
||||
setNotice("조회 가능한 관리기관이 없습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("GIS 관리 관리기관 조회 실패:", error);
|
||||
if (mounted) {
|
||||
setMngInstOptions([]);
|
||||
setNotice("관리기관 목록을 불러오지 못했습니다.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const [facilityTypes, managementOptions] = await Promise.all([
|
||||
getData(`v1/map/gisUploadFacilityTypes?screenId=${SCREEN_ID}`),
|
||||
getData("v1/fcltppl/mngInsts"),
|
||||
]);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
const pplFacility = Array.isArray(facilityTypes)
|
||||
? facilityTypes.find(
|
||||
(item) => item?.facilityTypeCode === FACILITY_TYPE_CODE,
|
||||
)
|
||||
: null;
|
||||
if (pplFacility) {
|
||||
setFacility({
|
||||
...DEFAULT_FACILITY,
|
||||
...pplFacility,
|
||||
allowedExtensions: Array.isArray(pplFacility.allowedExtensions)
|
||||
? pplFacility.allowedExtensions
|
||||
: DEFAULT_FACILITY.allowedExtensions,
|
||||
});
|
||||
}
|
||||
|
||||
const sortedOptions = Array.isArray(managementOptions)
|
||||
? [...managementOptions].sort((a, b) =>
|
||||
String(a?.lwrInstNm ?? a?.mngInstCd ?? "").localeCompare(
|
||||
String(b?.lwrInstNm ?? b?.mngInstCd ?? ""),
|
||||
"ko",
|
||||
),
|
||||
)
|
||||
: [];
|
||||
setMngInstOptions(sortedOptions);
|
||||
} catch (error) {
|
||||
console.error("GIS 관리 초기 데이터 조회 실패:", error);
|
||||
if (mounted) {
|
||||
setNotice("GIS 관리 정보를 불러오지 못했습니다.");
|
||||
}
|
||||
await Promise.all([loadFacilityType(), loadManagementOptions()]);
|
||||
} finally {
|
||||
if (mounted) setIsLoading(false);
|
||||
}
|
||||
@@ -519,8 +558,13 @@ export default function GisManagementPopupPage() {
|
||||
const job = await startDownloadJob(
|
||||
`v1/map/gisUploads/swagePpl/downloadJobs?${params.toString()}`,
|
||||
);
|
||||
setNotice(`${selectedScopeLabel} GeoPackage 파일을 생성하고 있습니다.`);
|
||||
const readyJob = await waitForDownloadJob(job.jobId);
|
||||
setNotice(
|
||||
`${selectedScopeLabel} ${downloadStatusLabels[job.status] || "GeoPackage 파일을 생성하고 있습니다."}`,
|
||||
);
|
||||
const readyJob = await waitForDownloadJob(job.jobId, (currentJob) => {
|
||||
const statusLabel = downloadStatusLabels[currentJob.status];
|
||||
if (statusLabel) setNotice(`${selectedScopeLabel} ${statusLabel}`);
|
||||
});
|
||||
const response = await downloadGeoPackage(
|
||||
`v1/map/gisDownloadJobs/${encodeURIComponent(job.jobId)}/file`,
|
||||
);
|
||||
@@ -590,7 +634,11 @@ export default function GisManagementPopupPage() {
|
||||
: [],
|
||||
);
|
||||
setViewMode("validationErrors");
|
||||
setNotice(response.message ?? "GeoPackage 검증 오류가 있습니다.");
|
||||
setNotice(
|
||||
response.message ??
|
||||
uploadStatusLabels[response.status] ??
|
||||
"GeoPackage 검증 오류가 있습니다.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -599,7 +647,11 @@ export default function GisManagementPopupPage() {
|
||||
resetSelectedFile();
|
||||
setValidationErrors([]);
|
||||
setViewMode("history");
|
||||
setNotice(response.message ?? "GIS 업로드가 완료되었습니다.");
|
||||
setNotice(
|
||||
response.message ??
|
||||
uploadStatusLabels[response.status] ??
|
||||
"GIS 업로드가 완료되었습니다.",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("GIS 업로드 실패:", error);
|
||||
alert(
|
||||
@@ -623,8 +675,14 @@ export default function GisManagementPopupPage() {
|
||||
const job = await startDownloadJob(
|
||||
`v1/map/gisUploads/${encodeURIComponent(selectedHistory.uploadId)}/downloadJobs`,
|
||||
);
|
||||
setNotice("선택한 현황의 GeoPackage 파일을 생성하고 있습니다.");
|
||||
const readyJob = await waitForDownloadJob(job.jobId);
|
||||
setNotice(
|
||||
downloadStatusLabels[job.status] ||
|
||||
"선택한 현황의 GeoPackage 파일을 생성하고 있습니다.",
|
||||
);
|
||||
const readyJob = await waitForDownloadJob(job.jobId, (currentJob) => {
|
||||
const statusLabel = downloadStatusLabels[currentJob.status];
|
||||
if (statusLabel) setNotice(statusLabel);
|
||||
});
|
||||
const response = await downloadGeoPackage(
|
||||
`v1/map/gisDownloadJobs/${encodeURIComponent(job.jobId)}/file`,
|
||||
);
|
||||
@@ -736,7 +794,7 @@ export default function GisManagementPopupPage() {
|
||||
|
||||
<label className="flex min-w-[30rem] flex-1 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
업로드 파일 ({facility.facilityTypeName}, {allowedExtensionText})
|
||||
업로드 파일 ({facilityTypeDisplayName}, {allowedExtensionText})
|
||||
</div>
|
||||
<Input
|
||||
value={selectedFile?.name ?? ""}
|
||||
@@ -797,7 +855,7 @@ export default function GisManagementPopupPage() {
|
||||
disabled={!isManagementSelected || isLoading}
|
||||
onClick={() => void handleRefreshHistories()}
|
||||
>
|
||||
조회
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -904,7 +962,7 @@ export default function GisManagementPopupPage() {
|
||||
disabled={!isManagementSelected || isLoading}
|
||||
onClick={() => void handleRefreshHistories()}
|
||||
>
|
||||
조회
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getCommonCodes } from "@/components/lib/common/apiHelpers";
|
||||
|
||||
type CodeLabelMap = Record<string, string>;
|
||||
|
||||
const COMMON_CODE_GROUPS = {
|
||||
uploadStatus: "GISULDSTTS",
|
||||
downloadStatus: "GISDWNLDSTTS",
|
||||
facilityType: "SNFCLTSE",
|
||||
} as const;
|
||||
|
||||
const DEFAULT_UPLOAD_STATUS_LABELS: CodeLabelMap = {
|
||||
COMPLETED: "업로드완료",
|
||||
VALIDATION_FAILED: "검증실패",
|
||||
FAILED: "업로드실패",
|
||||
};
|
||||
|
||||
const DEFAULT_DOWNLOAD_STATUS_LABELS: CodeLabelMap = {
|
||||
QUEUED: "작업대기",
|
||||
RUNNING: "파일생성중",
|
||||
READY: "다운로드준비완료",
|
||||
FAILED: "파일생성실패",
|
||||
};
|
||||
|
||||
const DEFAULT_FACILITY_TYPE_LABELS: CodeLabelMap = {
|
||||
PPL: "하수관로",
|
||||
MNHL: "맨홀",
|
||||
};
|
||||
|
||||
const cachedLabelMaps = new Map<string, CodeLabelMap>();
|
||||
const pendingLabelMaps = new Map<string, Promise<CodeLabelMap>>();
|
||||
|
||||
/** 같은 브라우저 세션에서 각 GIS 관리 코드그룹을 한 번만 조회한다. */
|
||||
const loadCodeLabelMap = async (
|
||||
upCd: string,
|
||||
fallback: CodeLabelMap,
|
||||
): Promise<CodeLabelMap> => {
|
||||
const cached = cachedLabelMaps.get(upCd);
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = pendingLabelMaps.get(upCd);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = getCommonCodes(upCd)
|
||||
.then((codes) => {
|
||||
const labels = { ...fallback };
|
||||
codes.forEach((code) => {
|
||||
if (code.lwpoCd && code.lwpoCdNm) {
|
||||
labels[code.lwpoCd] = code.lwpoCdNm;
|
||||
}
|
||||
});
|
||||
cachedLabelMaps.set(upCd, labels);
|
||||
return labels;
|
||||
})
|
||||
.finally(() => {
|
||||
pendingLabelMaps.delete(upCd);
|
||||
});
|
||||
|
||||
pendingLabelMaps.set(upCd, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
/** GIS 관리 화면의 코드값은 유지하고 상태·시설구분 표시명만 CMS 기준으로 제공한다. */
|
||||
export const useGisManagementCommonCodes = () => {
|
||||
const [uploadStatusLabels, setUploadStatusLabels] =
|
||||
useState<CodeLabelMap>(
|
||||
cachedLabelMaps.get(COMMON_CODE_GROUPS.uploadStatus) ??
|
||||
DEFAULT_UPLOAD_STATUS_LABELS,
|
||||
);
|
||||
const [downloadStatusLabels, setDownloadStatusLabels] =
|
||||
useState<CodeLabelMap>(
|
||||
cachedLabelMaps.get(COMMON_CODE_GROUPS.downloadStatus) ??
|
||||
DEFAULT_DOWNLOAD_STATUS_LABELS,
|
||||
);
|
||||
const [facilityTypeLabels, setFacilityTypeLabels] =
|
||||
useState<CodeLabelMap>(
|
||||
cachedLabelMaps.get(COMMON_CODE_GROUPS.facilityType) ??
|
||||
DEFAULT_FACILITY_TYPE_LABELS,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const applyLabels = (
|
||||
upCd: string,
|
||||
fallback: CodeLabelMap,
|
||||
setter: (labels: CodeLabelMap) => void,
|
||||
) => {
|
||||
loadCodeLabelMap(upCd, fallback)
|
||||
.then((labels) => {
|
||||
if (!cancelled) setter(labels);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`${upCd} 공통코드 조회에 실패했습니다.`, error);
|
||||
});
|
||||
};
|
||||
|
||||
applyLabels(
|
||||
COMMON_CODE_GROUPS.uploadStatus,
|
||||
DEFAULT_UPLOAD_STATUS_LABELS,
|
||||
setUploadStatusLabels,
|
||||
);
|
||||
applyLabels(
|
||||
COMMON_CODE_GROUPS.downloadStatus,
|
||||
DEFAULT_DOWNLOAD_STATUS_LABELS,
|
||||
setDownloadStatusLabels,
|
||||
);
|
||||
applyLabels(
|
||||
COMMON_CODE_GROUPS.facilityType,
|
||||
DEFAULT_FACILITY_TYPE_LABELS,
|
||||
setFacilityTypeLabels,
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
uploadStatusLabels,
|
||||
downloadStatusLabels,
|
||||
facilityTypeLabels,
|
||||
};
|
||||
};
|
||||
@@ -5,8 +5,10 @@ import { DateMonthPicker } from "@common/business_components/ui/date_month_picke
|
||||
import {
|
||||
StandardPopupLayout,
|
||||
type UiHeadProps,
|
||||
type UiTableSort,
|
||||
} from "@common/business_components";
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
@@ -28,16 +30,21 @@ import {
|
||||
const INFTWT_RESULT_PAGE_SIZE = 10;
|
||||
|
||||
const INFTWT_RESULT_HEADLIST: UiHeadProps[] = [
|
||||
{ id: "rowNumber", title: "순번" },
|
||||
{
|
||||
id: "rowNumber",
|
||||
title: "순번",
|
||||
sortable: true,
|
||||
sortKey: "inftwtAnlsSn",
|
||||
},
|
||||
{ id: "ctpvNm", title: "시도" },
|
||||
{ id: "sggNm", title: "시군구" },
|
||||
{ id: "swtrmtFcltNm", title: "하수처리시설" },
|
||||
{ id: "aplcnYm", title: "적용연월" },
|
||||
{ id: "drtwSwtcRt", title: "오수 전환율" },
|
||||
{ id: "anlsInfo", title: "침입수 분석정보" },
|
||||
{ id: "regDt", title: "분석일자" },
|
||||
{ id: "swtrmtFcltNm", title: "하수처리시설", sortable: true },
|
||||
{ id: "aplcnYm", title: "적용연월", sortable: true },
|
||||
{ id: "drtwSwtcRt", title: "오수 전환율", sortable: true },
|
||||
{ id: "anlsInfo", title: "침입수 분석정보", sortable: true },
|
||||
{ id: "regDt", title: "분석일자", sortable: true },
|
||||
{ id: "rgtrNm", title: "분석자" },
|
||||
{ id: "scsYn", title: "분석성공여부" },
|
||||
{ id: "scsYn", title: "분석성공여부", sortable: true },
|
||||
];
|
||||
|
||||
type InftwtResultFilters = {
|
||||
@@ -99,6 +106,7 @@ export default function InftwtResultPopupPage() {
|
||||
const [rows, setRows] = useState<InftwtResultRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -107,13 +115,22 @@ export default function InftwtResultPopupPage() {
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
const fetchRows = useCallback(
|
||||
async (page: number, nextFilters: InftwtResultFilters) => {
|
||||
async (
|
||||
page: number,
|
||||
nextFilters: InftwtResultFilters,
|
||||
nextSort: UiTableSort | undefined = sort,
|
||||
) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
page: String(page - 1),
|
||||
size: String(INFTWT_RESULT_PAGE_SIZE),
|
||||
scsYn: "Y",
|
||||
});
|
||||
queryParams.append("sort", "inftwtAnlsSn,desc");
|
||||
queryParams.append(
|
||||
"sort",
|
||||
nextSort
|
||||
? `${nextSort.key},${nextSort.direction}`
|
||||
: "inftwtAnlsSn,desc",
|
||||
);
|
||||
|
||||
if (nextFilters.aplcnYmFrom)
|
||||
queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
|
||||
@@ -155,14 +172,18 @@ export default function InftwtResultPopupPage() {
|
||||
setTotal(totalElements);
|
||||
setCurrentPage(page);
|
||||
},
|
||||
[ctpv, sgg],
|
||||
[ctpv, sgg, sort],
|
||||
);
|
||||
|
||||
const loadRows = useCallback(
|
||||
async (page = 1, nextFilters = filters) => {
|
||||
async (
|
||||
page = 1,
|
||||
nextFilters = filters,
|
||||
nextSort: UiTableSort | undefined = sort,
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await fetchRows(page, nextFilters);
|
||||
await fetchRows(page, nextFilters, nextSort);
|
||||
} catch (error) {
|
||||
console.error("침입수 분석결과 목록 조회 중 오류:", error);
|
||||
alert("침입수 분석결과 목록을 불러오지 못했습니다.");
|
||||
@@ -172,11 +193,11 @@ export default function InftwtResultPopupPage() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[alert, fetchRows, filters],
|
||||
[alert, fetchRows, filters, sort],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRows(1, DEFAULT_FILTERS);
|
||||
void loadRows(1, DEFAULT_FILTERS, undefined);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -190,7 +211,15 @@ export default function InftwtResultPopupPage() {
|
||||
setFilters(DEFAULT_FILTERS);
|
||||
setSelectedSwagePrcsFclt("0");
|
||||
setSelectedRow(null);
|
||||
await loadRows(1, DEFAULT_FILTERS);
|
||||
setSort(undefined);
|
||||
await loadRows(1, DEFAULT_FILTERS, undefined);
|
||||
};
|
||||
|
||||
/** 정렬 변경 시 선택 결과를 해제하고 서버 첫 페이지를 조회한다. */
|
||||
const handleSortChange = (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setSelectedRow(null);
|
||||
void loadRows(1, filters, nextSort);
|
||||
};
|
||||
|
||||
const handleRowClick = (index?: number) => {
|
||||
@@ -210,15 +239,19 @@ export default function InftwtResultPopupPage() {
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await getData(
|
||||
`v1/inftwtAnlsRslt/ppl/${selectedRow.inftwtAnlsSn}`,
|
||||
);
|
||||
const excelRows = Array.isArray(response?.content)
|
||||
? response.content
|
||||
: [];
|
||||
const response = await fetchAllPages<any, Record<string, unknown>>({
|
||||
fetchPage: (page, size) =>
|
||||
getData(
|
||||
`v1/inftwtAnlsRslt/ppl/${selectedRow.inftwtAnlsSn}?page=${page}&size=${size}`,
|
||||
),
|
||||
getItems: (page) =>
|
||||
Array.isArray(page?.content) ? page.content : [],
|
||||
getTotal: (page) => Number(page?.totalElements ?? 0),
|
||||
});
|
||||
const excelRows = response.items;
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: excelRows as JSON[],
|
||||
data: excelRows as unknown as JSON[],
|
||||
fileName: `침입수분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.inftwtAnlsSn}`,
|
||||
sheetNameArray: ["침입수 분석결과"],
|
||||
headerRange: "A1:D1",
|
||||
@@ -355,6 +388,8 @@ export default function InftwtResultPopupPage() {
|
||||
rowId={(row) => String(row.inftwtAnlsSn)}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="110rem"
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
actions={
|
||||
<Button type="button" onClick={() => void handleDownload()}>
|
||||
엑셀다운로드
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
enabled: boolean;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
onClose: () => void;
|
||||
children?: ReactNode;
|
||||
showToggle?: boolean;
|
||||
};
|
||||
|
||||
/** 침수흔적도 설정과 같은 제목·토글·닫기·본문 여백을 제공한다. */
|
||||
export default function LayerSettingCard({
|
||||
title,
|
||||
enabled,
|
||||
onEnabledChange,
|
||||
onClose,
|
||||
children,
|
||||
showToggle = true,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="w-full overflow-visible rounded-[6px] border border-border bg-background shadow-blur">
|
||||
<div className="flex min-h-[28px] items-center justify-between border-b border-border px-2">
|
||||
<span className="text-[1.15rem] font-semibold text-label">{title}</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{showToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
aria-label={`${title} 적용 여부`}
|
||||
title={enabled ? `${title} 적용 중` : `${title} 미적용`}
|
||||
className={`relative h-[16px] w-[28px] rounded-full border transition-colors ${enabled ? "border-primary bg-primary" : "border-[#94a3b8] bg-[#cbd5e1]"}`}
|
||||
onClick={() => onEnabledChange(!enabled)}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1/2 h-[12px] w-[12px] -translate-y-1/2 rounded-full bg-white shadow-sm transition-[left] ${enabled ? "left-[14px]" : "left-[2px]"}`}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${title} 설정 닫기`}
|
||||
title="닫기"
|
||||
className="grid h-[20px] w-[20px] place-items-center bg-transparent text-[1.3rem] leading-none text-muted-foreground hover:text-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{children ? (
|
||||
<div className="px-2 py-1 text-[1.15rem] text-label">{children}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+34
-25
@@ -9,9 +9,11 @@ import { getData } from "@/components/lib/api";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import useStore from "@/store/useStore";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
import { useUserStore } from "@/shared/lib/use-user-store";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from "../common/table";
|
||||
import {
|
||||
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS,
|
||||
PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS,
|
||||
@@ -33,6 +35,7 @@ import {
|
||||
|
||||
type DetailViewRow = ReturnType<typeof toPipeAnalysisDetailViewRows>[number];
|
||||
const PIPE_ANALYSIS_DETAIL_PAGE_SIZE = 10;
|
||||
const PIPE_ANALYSIS_MAP_WINDOW_NAME = "sn-sewerage-map-root";
|
||||
const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [
|
||||
"rowNo",
|
||||
"ctpvNm",
|
||||
@@ -205,9 +208,20 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
void loadRows(page, appliedFilters);
|
||||
};
|
||||
|
||||
/** 선택 관로를 지도에 표시하고 상세창의 최상위 하수관망도 부모창을 앞으로 가져온다. */
|
||||
const handleRowClick = (row: DetailViewRow) => {
|
||||
const targetWindow = window.opener?.opener ?? window.opener;
|
||||
targetWindow?.postMessage(
|
||||
if (!targetWindow || targetWindow.closed) return;
|
||||
|
||||
// Chrome가 다른 탭/창의 focus()만 무시하는 경우를 대비해 사용자 클릭 안에서
|
||||
// 기존 최상위 지도창을 고정 이름으로 다시 선택한 뒤 포커스를 요청한다.
|
||||
targetWindow.name = targetWindow.name || PIPE_ANALYSIS_MAP_WINDOW_NAME;
|
||||
const namedTargetWindow =
|
||||
window.open("", targetWindow.name) ?? targetWindow;
|
||||
window.opener?.blur();
|
||||
window.blur();
|
||||
namedTargetWindow.focus();
|
||||
targetWindow.postMessage(
|
||||
{
|
||||
type: PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE,
|
||||
row,
|
||||
@@ -224,12 +238,12 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetchRows(
|
||||
1,
|
||||
appliedFilters,
|
||||
Math.max(total, 1000),
|
||||
);
|
||||
const exportRows = response.rows.map((row) => ({
|
||||
const response = await fetchAllPages({
|
||||
fetchPage: (page, size) => fetchRows(page + 1, appliedFilters, size),
|
||||
getItems: (page) => page.rows,
|
||||
getTotal: (page) => page.total,
|
||||
});
|
||||
const exportRows = response.items.map((row) => ({
|
||||
rowNo: row.rowNo,
|
||||
ctpvNm: row.ctpvNm,
|
||||
sggNm: row.sggNm,
|
||||
@@ -264,15 +278,9 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
const materialOptions = useMemo(() => {
|
||||
const options = [
|
||||
{ value: "0", label: "전체" },
|
||||
...Array.from(
|
||||
new Set(
|
||||
rows
|
||||
.map((row) => String(row.materialCode ?? "").trim())
|
||||
.filter((value) => value && value !== "0"),
|
||||
),
|
||||
).map((value) => ({
|
||||
value,
|
||||
label: resolveMaterialName(value),
|
||||
...MS_SWAGE_PPL_CODES.map((item) => ({
|
||||
value: item.val.trim(),
|
||||
label: item.text,
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -288,7 +296,7 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [baseMaterialCode, rows]);
|
||||
}, [baseMaterialCode]);
|
||||
|
||||
const titleSuffix = useMemo(() => {
|
||||
const locationParts: string[] = [];
|
||||
@@ -345,7 +353,7 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
용도
|
||||
</div>
|
||||
<select
|
||||
className="sn-popup-input w-[14rem] !h-[3.2rem] !text-xl"
|
||||
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
|
||||
value={filters.useCd}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -355,10 +363,11 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
<option value="SBA001">합류관</option>
|
||||
<option value="SBA002">차집관</option>
|
||||
<option value="SBA003">우수관</option>
|
||||
<option value="SBA004">오수관</option>
|
||||
{MS_SWAGE_PPL_USE_CODES.map((item) => (
|
||||
<option key={item.val} value={item.val}>
|
||||
{item.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -367,7 +376,7 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
관재질
|
||||
</div>
|
||||
<select
|
||||
className="sn-popup-input w-[14rem] !h-[3.2rem] !text-xl"
|
||||
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
|
||||
value={filters.materialCode}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -389,7 +398,7 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
관경 시작
|
||||
</div>
|
||||
<Input
|
||||
className="sn-popup-input w-[12rem] !h-[3.2rem] !text-xl"
|
||||
className="sn-popup-input !h-[3.2rem] w-[12rem] !text-xl"
|
||||
value={filters.diameterFrom}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -408,7 +417,7 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
관경 종료
|
||||
</div>
|
||||
<Input
|
||||
className="sn-popup-input w-[12rem] !h-[3.2rem] !text-xl"
|
||||
className="sn-popup-input !h-[3.2rem] w-[12rem] !text-xl"
|
||||
value={filters.diameterTo}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
|
||||
@@ -119,6 +119,9 @@ export default function PipeAnalysisPopupPage() {
|
||||
const zones = await fetchPipeAnalysisZoneOptions({
|
||||
ctpvCd: draftFilters.ctpvCd,
|
||||
sggCd: draftFilters.sggCd,
|
||||
sggNm: sgg.find(
|
||||
(option) => String(option.val).trim() === draftFilters.sggCd,
|
||||
)?.text,
|
||||
scopeRegionCode: scope.regionCode,
|
||||
});
|
||||
if (!mounted) return;
|
||||
@@ -133,7 +136,7 @@ export default function PipeAnalysisPopupPage() {
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [draftFilters.ctpvCd, draftFilters.sggCd, scope.regionCode]);
|
||||
}, [draftFilters.ctpvCd, draftFilters.sggCd, scope.regionCode, sgg]);
|
||||
|
||||
const fetchSummary = useCallback(
|
||||
async (nextFilters: PipeAnalysisSummaryFilters) => {
|
||||
|
||||
@@ -654,15 +654,23 @@ export const buildPipeAnalysisFileDate = () =>
|
||||
export const fetchPipeAnalysisZoneOptions = async ({
|
||||
ctpvCd,
|
||||
sggCd,
|
||||
sggNm,
|
||||
scopeRegionCode,
|
||||
}: {
|
||||
ctpvCd: string;
|
||||
sggCd: string;
|
||||
sggNm?: string;
|
||||
scopeRegionCode?: string;
|
||||
}): Promise<PipeAnalysisZoneOption[]> => {
|
||||
const filters: string[] = [];
|
||||
const selectedSggCd = String(sggCd ?? "").trim();
|
||||
const selectedSggNm = String(sggNm ?? "").trim();
|
||||
if (sggCd && sggCd !== "0") {
|
||||
filters.push(`sgg_cd='${sggCd}'`);
|
||||
// 처리구역 원천의 시군구코드가 잘못된 경우도 있어 시도 범위로 받은 뒤
|
||||
// 기준코드 또는 시군구명 중 하나가 일치하는 처리구역만 남긴다.
|
||||
const selectedCtpvCd =
|
||||
ctpvCd && ctpvCd !== "0" ? ctpvCd : selectedSggCd.slice(0, 2);
|
||||
filters.push(`ctpv_cd='${selectedCtpvCd}'`);
|
||||
} else if (ctpvCd && ctpvCd !== "0") {
|
||||
filters.push(`ctpv_cd='${ctpvCd}'`);
|
||||
} else if (scopeRegionCode) {
|
||||
@@ -679,7 +687,7 @@ export const fetchPipeAnalysisZoneOptions = async ({
|
||||
request: "GetFeature",
|
||||
typeName: "hasudo:tb_ms_swage_prcs_zone",
|
||||
outputFormat: "application/json",
|
||||
propertyName: "ctpv_cd,sgg_cd,swtrmt_zone_nm",
|
||||
propertyName: "ctpv_cd,sgg_cd,sgg_nm,swtrmt_zone_nm",
|
||||
});
|
||||
|
||||
if (filters.length > 0) {
|
||||
@@ -698,12 +706,22 @@ export const fetchPipeAnalysisZoneOptions = async ({
|
||||
features.forEach((feature: any) => {
|
||||
const properties = feature?.properties ?? {};
|
||||
const zoneName = String(properties.swtrmt_zone_nm ?? "").trim();
|
||||
const featureSggCd = String(properties.sgg_cd ?? "").trim();
|
||||
const featureSggNm = String(properties.sgg_nm ?? "").trim();
|
||||
if (!zoneName) return;
|
||||
if (
|
||||
selectedSggCd &&
|
||||
selectedSggCd !== "0" &&
|
||||
featureSggCd !== selectedSggCd &&
|
||||
(!selectedSggNm || featureSggNm !== selectedSggNm)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
dedupMap.set(zoneName, {
|
||||
value: zoneName,
|
||||
label: zoneName,
|
||||
ctpvCd: String(properties.ctpv_cd ?? "").trim(),
|
||||
sggCd: String(properties.sgg_cd ?? "").trim(),
|
||||
sggCd: selectedSggCd !== "0" ? selectedSggCd : featureSggCd,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+297
-14
@@ -25,6 +25,7 @@
|
||||
import { getData, postData } from "@/components/lib/api";
|
||||
import { VerticalTable } from "@/components/ui/verticalTable";
|
||||
import { useGisContext } from "@common/business_components/ui/map/Gis";
|
||||
import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
||||
import { Button } from "@common/components/ui";
|
||||
import { useEffect, useRef, useState, useMemo } from "react";
|
||||
import Style from "ol/style/Style.js";
|
||||
@@ -35,6 +36,7 @@ import { getLayerContentComponent } from "../common";
|
||||
import {
|
||||
FldngWtlvMapCol,
|
||||
FldngWtlvMapCol2,
|
||||
FldngDpthMapCol,
|
||||
SwtrmtFcltCol,
|
||||
SwtrmtFcltCol2,
|
||||
SwtrmtPrcsZoneCol,
|
||||
@@ -177,7 +179,24 @@ const FeatureClickEventPopup = ({
|
||||
const [chmbrYdnts, setChmbrYdnts] = useState<string>(""); // 로딩 상태
|
||||
const linkedFacilityFeatureRef = useRef<any>(null);
|
||||
const linkedFacilityOriginalStyleRef = useRef<any>(null);
|
||||
const flowDirectionBasisRequestCacheRef = useRef(
|
||||
new Map<string, Promise<any | null>>(),
|
||||
);
|
||||
const [mngInstOptions, setMngInstOptions] = useState<MngInstOption[]>([]);
|
||||
const [floodDepthRows, setFloodDepthRows] = useState<
|
||||
Record<string, unknown>[]
|
||||
>([]);
|
||||
const [floodWtlvRows, setFloodWtlvRows] = useState<Record<string, unknown>[]>(
|
||||
[],
|
||||
);
|
||||
const [selectedFloodDepthFid, setSelectedFloodDepthFid] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [selectedFloodWtlvFid, setSelectedFloodWtlvFid] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const floodDepthYearListRef = useRef<HTMLDivElement>(null);
|
||||
const floodWtlvListRef = useRef<HTMLDivElement>(null);
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
|
||||
// 🎯 관로관리번호 추출
|
||||
@@ -466,7 +485,7 @@ const FeatureClickEventPopup = ({
|
||||
break;
|
||||
}
|
||||
case "tb_ms_fldng_wtlv_map": {
|
||||
result = "침수흔적도";
|
||||
result = "침수흔적위선";
|
||||
setHeadlist(FldngWtlvMapCol);
|
||||
setLayerSpecificNm(
|
||||
normalizeFldngWtlvMapDisplayId(
|
||||
@@ -475,6 +494,14 @@ const FeatureClickEventPopup = ({
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "tb_ms_fldng_dpth_map": {
|
||||
result = "침수흔적심선";
|
||||
setHeadlist(FldngDpthMapCol);
|
||||
setLayerSpecificNm(
|
||||
String(featureId ?? "").replace(/^tb_ms_fldng_dpth_map\./, ""),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "tb_ms_stofcbr": {
|
||||
result = "우수토실";
|
||||
setHeadlist(stofcbrCol);
|
||||
@@ -539,11 +566,22 @@ const FeatureClickEventPopup = ({
|
||||
break;
|
||||
}
|
||||
case "tb_ms_fldng_wtlv_map": {
|
||||
result = "침수흔적도";
|
||||
result = "침수흔적위선";
|
||||
setHeadlist(FldngWtlvMapCol2);
|
||||
setLayerSpecificNm(normalizeFldngWtlvMapDisplayId(featureData.fid));
|
||||
break;
|
||||
}
|
||||
case "tb_ms_fldng_dpth_map": {
|
||||
result = "침수흔적심선";
|
||||
setHeadlist(FldngDpthMapCol);
|
||||
setLayerSpecificNm(
|
||||
String(featureData.fid ?? featureId ?? "").replace(
|
||||
/^tb_ms_fldng_dpth_map\./,
|
||||
"",
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "tb_ms_stofcbr": {
|
||||
result = "우수토실";
|
||||
setHeadlist(stofcbrCol2);
|
||||
@@ -596,13 +634,14 @@ const FeatureClickEventPopup = ({
|
||||
|
||||
// 🎯 기본 데이터 포맷팅
|
||||
const normalizedRegionData =
|
||||
selectedLayerName === "침수흔적도"
|
||||
selectedLayerName === "침수흔적위선" ||
|
||||
selectedLayerName === "침수흔적심선"
|
||||
? withResolvedRegionNames(data)
|
||||
: data;
|
||||
const formattedData = withMngInstName(
|
||||
formatDisplayData(normalizedRegionData),
|
||||
);
|
||||
if (selectedLayerName === "침수흔적도") {
|
||||
if (selectedLayerName === "침수흔적위선") {
|
||||
const fldngBgngDtm = formatFldngWtlvDateTime(
|
||||
formattedData.bgngYmd ??
|
||||
formattedData.bgng_ymd ??
|
||||
@@ -637,6 +676,7 @@ const FeatureClickEventPopup = ({
|
||||
formattedData.instlYmd = "확인불가";
|
||||
}
|
||||
}
|
||||
let mergedDetailData = formattedData;
|
||||
|
||||
setFetchData(formattedData);
|
||||
setTableData(formattedData);
|
||||
@@ -684,7 +724,7 @@ const FeatureClickEventPopup = ({
|
||||
case "하수처리구역":
|
||||
setButtonChk(false);
|
||||
break;
|
||||
case "침수흔적도":
|
||||
case "침수흔적위선":
|
||||
setButtonChk(false);
|
||||
break;
|
||||
case "우수토실":
|
||||
@@ -742,11 +782,11 @@ const FeatureClickEventPopup = ({
|
||||
const result = await getData(apiEndpoint);
|
||||
|
||||
if (result) {
|
||||
const mergedData = mergeDetailData(formattedData, result);
|
||||
mergedDetailData = mergeDetailData(mergedDetailData, result);
|
||||
const processedApiData = withMngInstName(
|
||||
codeMapper({
|
||||
layerName: selectedLayerName,
|
||||
data: mergedData,
|
||||
data: mergedDetailData,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -759,7 +799,67 @@ const FeatureClickEventPopup = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedLayerName === "침수흔적도" && featureId) {
|
||||
if (selectedLayerName === "하수관로" && canRequestDetail) {
|
||||
try {
|
||||
const escapeCqlLiteral = (value: string) =>
|
||||
value.replace(/'/g, "''");
|
||||
const requestCacheKey = `${fcltMngNo}::${mngInstCd}`;
|
||||
const params = new URLSearchParams({
|
||||
lyrTypeNm: "tb_ms_swage_ppl",
|
||||
includeFlowDirectionBasis: "true",
|
||||
cqlFiter:
|
||||
`fclt_mng_no='${escapeCqlLiteral(fcltMngNo)}' and ` +
|
||||
`mng_inst_cd='${escapeCqlLiteral(mngInstCd)}'`,
|
||||
page: "0",
|
||||
size: "1",
|
||||
});
|
||||
let flowDirectionRequest =
|
||||
flowDirectionBasisRequestCacheRef.current.get(requestCacheKey);
|
||||
if (!flowDirectionRequest) {
|
||||
flowDirectionRequest = getData(`v1/fclts?${params.toString()}`)
|
||||
.then((response) =>
|
||||
Array.isArray(response?.data) ? response.data[0] : null,
|
||||
)
|
||||
.catch((error) => {
|
||||
flowDirectionBasisRequestCacheRef.current.delete(
|
||||
requestCacheKey,
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
flowDirectionBasisRequestCacheRef.current.set(
|
||||
requestCacheKey,
|
||||
flowDirectionRequest,
|
||||
);
|
||||
}
|
||||
const enrichedPipe = await flowDirectionRequest;
|
||||
if (enrichedPipe) {
|
||||
mergedDetailData = mergeDetailData(
|
||||
mergedDetailData,
|
||||
enrichedPipe,
|
||||
);
|
||||
const processedPipeData = withMngInstName(
|
||||
codeMapper({
|
||||
layerName: selectedLayerName,
|
||||
data: mergedDetailData,
|
||||
}),
|
||||
);
|
||||
setFetchData(processedPipeData);
|
||||
setTableData(processedPipeData);
|
||||
}
|
||||
} catch (flowDirectionError) {
|
||||
console.warn(
|
||||
"관로 유하방향 근거 조회 실패, 기존 상세정보를 유지합니다.",
|
||||
flowDirectionError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedLayerName === "침수흔적위선" && featureId) {
|
||||
const depthLookupKey = normalizeFldngWtlvMapApiFid(featureId);
|
||||
setFloodDepthRows([]);
|
||||
setFloodWtlvRows([]);
|
||||
setSelectedFloodDepthFid(null);
|
||||
setSelectedFloodWtlvFid(null);
|
||||
try {
|
||||
const normalizedFloodMapFid =
|
||||
normalizeFldngWtlvMapApiFid(featureId);
|
||||
@@ -780,6 +880,35 @@ const FeatureClickEventPopup = ({
|
||||
} catch (rainError) {
|
||||
console.warn("침수강우분석 조회 실패:", rainError);
|
||||
}
|
||||
try {
|
||||
if (depthLookupKey) {
|
||||
const depthRows = await getData(
|
||||
`v1/fldng/hstry/depth-map/${encodeURIComponent(depthLookupKey)}`,
|
||||
);
|
||||
const depthPayload = depthRows?.data ?? depthRows;
|
||||
const depthItems = Array.isArray(depthPayload)
|
||||
? depthPayload
|
||||
: depthPayload?.depthMaps;
|
||||
const waterLevelItems = Array.isArray(
|
||||
depthPayload?.waterLevelMaps,
|
||||
)
|
||||
? depthPayload.waterLevelMaps
|
||||
: [];
|
||||
const normalizedDepthRows = Array.isArray(depthItems)
|
||||
? depthItems.filter((row) => row && typeof row === "object")
|
||||
: [];
|
||||
const normalizedWaterLevelRows = waterLevelItems.filter(
|
||||
(row: unknown) => row && typeof row === "object",
|
||||
);
|
||||
setFloodDepthRows(normalizedDepthRows);
|
||||
setFloodWtlvRows(normalizedWaterLevelRows);
|
||||
// 최초에는 연계 항목을 선택하지 않고 버튼 목록만 표시한다.
|
||||
setSelectedFloodDepthFid(null);
|
||||
setSelectedFloodWtlvFid(null);
|
||||
}
|
||||
} catch (depthError) {
|
||||
console.warn("침수깊이지도 조회 실패:", depthError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("데이터 로딩 중 오류:", error);
|
||||
@@ -892,7 +1021,7 @@ const FeatureClickEventPopup = ({
|
||||
className="absolute right-0 top-0 z-[50] flex h-[calc(100vh-5rem)] w-[350px] flex-col overflow-hidden border-r border-border bg-background transition-all duration-300"
|
||||
>
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<div className="card-header relative flex items-center justify-between bg-primary p-4">
|
||||
<div className="card-header relative flex items-center justify-between bg-primary p-4 pr-12">
|
||||
<div className="flex flex-1 items-center space-x-4">
|
||||
{surveyMode && (
|
||||
<h3 className="whitespace-nowrap text-2xl font-medium text-primary-foreground">
|
||||
@@ -912,11 +1041,14 @@ const FeatureClickEventPopup = ({
|
||||
관리번호 : {layerSpecificNm}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className={`flex shrink-0 items-center space-x-3 ${layerNm === "하수관로" ? "mr-8" : ""}`}
|
||||
>
|
||||
{/* 하수관로일 때만 Infil/RDII 버튼 표시 */}
|
||||
{layerNm === "하수관로" && (
|
||||
<>
|
||||
<Button
|
||||
className="px-2"
|
||||
onClick={() => {
|
||||
const popupWindow = openWindowPopup("infilChart", {
|
||||
pplMngNo,
|
||||
@@ -932,6 +1064,7 @@ const FeatureClickEventPopup = ({
|
||||
침입수분석
|
||||
</Button>
|
||||
<Button
|
||||
className="px-2"
|
||||
onClick={() => {
|
||||
const popupWindow = openWindowPopup("rdiiChart", {
|
||||
pplMngNo,
|
||||
@@ -966,19 +1099,169 @@ const FeatureClickEventPopup = ({
|
||||
</div>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
className="text-primary-foreground/80 flex items-center space-x-4 hover:text-primary-foreground focus:outline-none"
|
||||
className="text-primary-foreground/80 absolute right-2 top-1/2 z-10 h-8 w-8 -translate-y-1/2 p-0 hover:text-primary-foreground focus:outline-none"
|
||||
size="xs"
|
||||
aria-label="시설 상세정보 닫기"
|
||||
title="닫기"
|
||||
>
|
||||
✕
|
||||
<span aria-hidden="true">✕</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden"
|
||||
className="flex flex-1 flex-col overflow-y-auto overflow-x-hidden"
|
||||
ref={contentRef}
|
||||
>
|
||||
<VerticalTable
|
||||
headName={
|
||||
layerNm === "하수관로" && tableData?.elapsedYears ? (
|
||||
layerNm === "침수흔적위선" &&
|
||||
(floodDepthRows.length > 0 || floodWtlvRows.length > 0) ? (
|
||||
<div className="flex w-full min-w-0 flex-col items-start gap-2">
|
||||
<span>기본정보</span>
|
||||
{floodDepthRows.length > 0 && (
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<span className="w-9 shrink-0 text-[1.1rem] font-semibold">
|
||||
심선
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="이전 연도"
|
||||
className="shrink-0 px-1 text-lg leading-none text-foreground"
|
||||
onClick={() =>
|
||||
floodDepthYearListRef.current?.scrollBy({
|
||||
left: -120,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<div
|
||||
ref={floodDepthYearListRef}
|
||||
className="flex min-w-0 flex-1 gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{[...floodDepthRows]
|
||||
.map((row, index) => ({
|
||||
row,
|
||||
index,
|
||||
year: String(
|
||||
row.fldng_yr ?? row.fldngYr ?? row.yr ?? "",
|
||||
).trim(),
|
||||
}))
|
||||
.filter(({ year }) => Boolean(year))
|
||||
.sort((left, right) => {
|
||||
const yearOrder = left.year.localeCompare(
|
||||
right.year,
|
||||
undefined,
|
||||
{ numeric: true },
|
||||
);
|
||||
return yearOrder !== 0
|
||||
? yearOrder
|
||||
: left.index - right.index;
|
||||
})
|
||||
.map(({ row, index, year }) => (
|
||||
<button
|
||||
key={`${String(row.fldng_dpth_map_sn ?? row.fid ?? "row")}-${index}`}
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const depthFid = String(
|
||||
row.fldng_dpth_map_sn ?? row.fid ?? "",
|
||||
).trim();
|
||||
if (depthFid) {
|
||||
setSelectedFloodDepthFid(depthFid);
|
||||
setSelectedFloodWtlvFid(null);
|
||||
const core = GisApp.Module.layer as any;
|
||||
await core.showSearchResultsFromGeoJSON?.(
|
||||
"tb_ms_fldng_dpth_map",
|
||||
`tb_ms_fldng_dpth_map.${depthFid.replace(/^tb_ms_fldng_dpth_map\./, "")}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
className={`shrink-0 rounded border px-3 py-1 text-[1.2rem] font-medium leading-5 ${selectedFloodDepthFid === String(row.fldng_dpth_map_sn ?? row.fid ?? "").trim() ? "border-blue-600 bg-white text-blue-700" : "border-border bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
{year}년
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="다음 연도"
|
||||
className="shrink-0 px-1 text-lg leading-none text-foreground"
|
||||
onClick={() =>
|
||||
floodDepthYearListRef.current?.scrollBy({
|
||||
left: 120,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{floodWtlvRows.length > 0 && (
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<span className="w-9 shrink-0 text-[1.1rem] font-semibold">
|
||||
위선
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="이전 위선"
|
||||
className="shrink-0 px-1 text-lg leading-none text-foreground"
|
||||
onClick={() =>
|
||||
floodWtlvListRef.current?.scrollBy({
|
||||
left: -120,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<div
|
||||
ref={floodWtlvListRef}
|
||||
className="flex min-w-0 flex-1 gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{floodWtlvRows.map((row, index) => {
|
||||
const waterLevelFid = String(row.fid ?? "").trim();
|
||||
const disasterName = String(
|
||||
row.fldng_dstr_nm ?? row.fldngDstrNm ?? "",
|
||||
).trim();
|
||||
return (
|
||||
<button
|
||||
key={`${waterLevelFid || "wtlv"}-${index}`}
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!waterLevelFid) return;
|
||||
setSelectedFloodWtlvFid(waterLevelFid);
|
||||
setSelectedFloodDepthFid(null);
|
||||
const core = GisApp.Module.layer as any;
|
||||
await core.showSearchResultsFromGeoJSON?.(
|
||||
"tb_ms_fldng_wtlv_map",
|
||||
`tb_ms_fldng_wtlv_map.${waterLevelFid.replace(/^tb_ms_fldng_wtlv_map\./, "")}`,
|
||||
);
|
||||
}}
|
||||
className={`shrink-0 rounded border px-3 py-1 text-[1.2rem] font-medium leading-5 ${selectedFloodWtlvFid === waterLevelFid ? "border-blue-600 bg-white text-blue-700" : "border-border bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
{disasterName || "재해명 없음"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="다음 위선"
|
||||
className="shrink-0 px-1 text-lg leading-none text-foreground"
|
||||
onClick={() =>
|
||||
floodWtlvListRef.current?.scrollBy({
|
||||
left: 120,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : layerNm === "하수관로" && tableData?.elapsedYears ? (
|
||||
<>
|
||||
기본정보{" "}
|
||||
<span className="ml-2 rounded-sm bg-red-50 px-2 py-0.5 text-[1.1rem] font-semibold text-red-700">
|
||||
|
||||
@@ -5,11 +5,13 @@ import { DateMonthPicker } from "@common/business_components/ui/date_month_picke
|
||||
import {
|
||||
StandardPopupLayout,
|
||||
type UiHeadProps,
|
||||
type UiTableSort,
|
||||
} from "@common/business_components";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { getData } from "@/components/lib/api";
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
|
||||
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
|
||||
@@ -28,18 +30,23 @@ import {
|
||||
const RDII_RESULT_PAGE_SIZE = 10;
|
||||
|
||||
const RDII_RESULT_HEADLIST: UiHeadProps[] = [
|
||||
{ id: "rowNumber", title: "순번" },
|
||||
{
|
||||
id: "rowNumber",
|
||||
title: "순번",
|
||||
sortable: true,
|
||||
sortKey: "rainInfwtAnlsSn",
|
||||
},
|
||||
{ id: "ctpvNm", title: "시도" },
|
||||
{ id: "sggNm", title: "시군구" },
|
||||
{ id: "swtrmtFcltNm", title: "하수처리시설" },
|
||||
{ id: "aplcnYm", title: "상수사용연월" },
|
||||
{ id: "drtwSwtcRt", title: "오수 전환율" },
|
||||
{ id: "srcNm", title: "RTK 계수 출처" },
|
||||
{ id: "cntbArea", title: "RDII 기여면적" },
|
||||
{ id: "anlsInfo", title: "RDII\n분석정보" },
|
||||
{ id: "regDt", title: "분석일자" },
|
||||
{ id: "swtrmtFcltNm", title: "하수처리시설", sortable: true },
|
||||
{ id: "aplcnYm", title: "상수사용연월", sortable: true },
|
||||
{ id: "drtwSwtcRt", title: "오수 전환율", sortable: true },
|
||||
{ id: "srcNm", title: "RTK 계수 출처", sortable: true },
|
||||
{ id: "cntbArea", title: "RDII 기여면적", sortable: true },
|
||||
{ id: "anlsInfo", title: "RDII\n분석정보", sortable: true },
|
||||
{ id: "regDt", title: "분석일자", sortable: true },
|
||||
{ id: "rgtrNm", title: "분석자" },
|
||||
{ id: "scsYn", title: "분석성공여부" },
|
||||
{ id: "scsYn", title: "분석성공여부", sortable: true },
|
||||
];
|
||||
|
||||
type RdiiResultFilters = {
|
||||
@@ -101,6 +108,7 @@ export default function RdiiResultPopupPage() {
|
||||
const [rows, setRows] = useState<RdiiResultRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -109,13 +117,22 @@ export default function RdiiResultPopupPage() {
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
const fetchRows = useCallback(
|
||||
async (page: number, nextFilters: RdiiResultFilters) => {
|
||||
async (
|
||||
page: number,
|
||||
nextFilters: RdiiResultFilters,
|
||||
nextSort: UiTableSort | undefined = sort,
|
||||
) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
page: String(page - 1),
|
||||
size: String(RDII_RESULT_PAGE_SIZE),
|
||||
scsYn: "Y",
|
||||
});
|
||||
queryParams.append("sort", "rainInfwtAnlsSn,desc");
|
||||
queryParams.append(
|
||||
"sort",
|
||||
nextSort
|
||||
? `${nextSort.key},${nextSort.direction}`
|
||||
: "rainInfwtAnlsSn,desc",
|
||||
);
|
||||
|
||||
if (nextFilters.aplcnYmFrom)
|
||||
queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
|
||||
@@ -157,14 +174,18 @@ export default function RdiiResultPopupPage() {
|
||||
setTotal(totalElements);
|
||||
setCurrentPage(page);
|
||||
},
|
||||
[ctpv, sgg],
|
||||
[ctpv, sgg, sort],
|
||||
);
|
||||
|
||||
const loadRows = useCallback(
|
||||
async (page = 1, nextFilters = filters) => {
|
||||
async (
|
||||
page = 1,
|
||||
nextFilters = filters,
|
||||
nextSort: UiTableSort | undefined = sort,
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await fetchRows(page, nextFilters);
|
||||
await fetchRows(page, nextFilters, nextSort);
|
||||
} catch (error) {
|
||||
console.error("RDII 분석결과 목록 조회 중 오류:", error);
|
||||
alert("RDII 분석결과 목록을 불러오지 못했습니다.");
|
||||
@@ -174,11 +195,11 @@ export default function RdiiResultPopupPage() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[alert, fetchRows, filters],
|
||||
[alert, fetchRows, filters, sort],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRows(1, DEFAULT_FILTERS);
|
||||
void loadRows(1, DEFAULT_FILTERS, undefined);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -192,7 +213,15 @@ export default function RdiiResultPopupPage() {
|
||||
setFilters(DEFAULT_FILTERS);
|
||||
setSelectedSwagePrcsFclt("0");
|
||||
setSelectedRow(null);
|
||||
await loadRows(1, DEFAULT_FILTERS);
|
||||
setSort(undefined);
|
||||
await loadRows(1, DEFAULT_FILTERS, undefined);
|
||||
};
|
||||
|
||||
/** 정렬 변경 시 선택 결과를 해제하고 서버 첫 페이지를 조회한다. */
|
||||
const handleSortChange = (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setSelectedRow(null);
|
||||
void loadRows(1, filters, nextSort);
|
||||
};
|
||||
|
||||
const handleRowClick = (index?: number) => {
|
||||
@@ -212,15 +241,19 @@ export default function RdiiResultPopupPage() {
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await getData(
|
||||
`v1/rdiiAnlsRslt/ppl/${selectedRow.rainInfwtAnlsSn}`,
|
||||
);
|
||||
const excelRows = Array.isArray(response?.content)
|
||||
? response.content
|
||||
: [];
|
||||
const response = await fetchAllPages<any, Record<string, unknown>>({
|
||||
fetchPage: (page, size) =>
|
||||
getData(
|
||||
`v1/rdiiAnlsRslt/ppl/${selectedRow.rainInfwtAnlsSn}?page=${page}&size=${size}`,
|
||||
),
|
||||
getItems: (page) =>
|
||||
Array.isArray(page?.content) ? page.content : [],
|
||||
getTotal: (page) => Number(page?.totalElements ?? 0),
|
||||
});
|
||||
const excelRows = response.items;
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: excelRows as JSON[],
|
||||
data: excelRows as unknown as JSON[],
|
||||
fileName: `RDII분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.rainInfwtAnlsSn}`,
|
||||
sheetNameArray: ["RDII 분석결과"],
|
||||
headerRange: "A1:D1",
|
||||
@@ -357,6 +390,8 @@ export default function RdiiResultPopupPage() {
|
||||
rowId={(row) => String(row.rainInfwtAnlsSn)}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="125rem"
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
actions={
|
||||
<Button type="button" onClick={() => void handleDownload()}>
|
||||
엑셀다운로드
|
||||
|
||||
@@ -295,7 +295,15 @@ export default function SbmrgDngrMapPopupPage() {
|
||||
pageSize={SBMRG_DNGR_MAP_PAGE_SIZE}
|
||||
onPageChange={(page) => void loadRows(page, appliedFilters)}
|
||||
onRowClick={handleRowClick}
|
||||
rowId={(row, index) => String(row.emphsMngRgnMngNo ?? index)}
|
||||
rowId={(row, index) =>
|
||||
[
|
||||
row.emphsMngRgnMngNo ?? "unknown",
|
||||
row.floodWtlvFrqVl ?? "unknown",
|
||||
row.ctpvCd ?? "unknown",
|
||||
row.sggCd ?? "unknown",
|
||||
index,
|
||||
].join(":")
|
||||
}
|
||||
/>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { UiHeadProps } from "@common/business_components/ui";
|
||||
export const SBMRG_DNGR_MAP_POPUP_KEY = "sbmrgDngrMapPopup";
|
||||
export const SBMRG_DNGR_MAP_LAYER_TYPE = "tb_ms_sbmrg_dngr_map";
|
||||
export const SBMRG_DNGR_MAP_LAYER_NAME = "침수위험지도(중점관리지역)";
|
||||
export const SBMRG_DNGR_MAP_PAGE_SIZE = 20;
|
||||
export const SBMRG_DNGR_MAP_PAGE_SIZE = 10;
|
||||
export const SBMRG_DNGR_MAP_SCOPE_SIZE = 2000;
|
||||
|
||||
export type SbmrgDngrMapFilters = {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from "@common/components";
|
||||
import { VerticalTable } from "@/components/ui/verticalTable";
|
||||
import { getCmsData } from "@/components/lib/api";
|
||||
import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkholeTypes";
|
||||
import { useSinkholeCommonCodes } from "./useSinkholeCommonCodes";
|
||||
|
||||
type Props = {
|
||||
detail: SinkholeDetail;
|
||||
@@ -74,6 +75,8 @@ export default function SinkholeDetailPanel({
|
||||
onClose,
|
||||
onSelectPipe,
|
||||
}: Props) {
|
||||
// 상세의 원천 복구상태 값은 유지하고 CMS에 대응하는 코드명만 표시한다.
|
||||
const { restoreStatusLabels } = useSinkholeCommonCodes();
|
||||
const [pipeUseCodeMap, setPipeUseCodeMap] = useState<Map<string, string>>(
|
||||
new Map(),
|
||||
);
|
||||
@@ -121,20 +124,25 @@ export default function SinkholeDetailPanel({
|
||||
}, [nearbyPipes]);
|
||||
|
||||
const summaryTableData = useMemo(
|
||||
() => [
|
||||
{
|
||||
acdntNo: display(detail.acdntNo),
|
||||
ocrnYmd: display(detail.ocrnYmd),
|
||||
fullAddress: display(detail.fullAddress || detail.daddr),
|
||||
sizeWidth: display(detail.sizeWidth),
|
||||
sizeLength: display(detail.sizeLength),
|
||||
sizeDepth: display(detail.sizeDepth),
|
||||
detailCause: display(detail.detailCause),
|
||||
restoreStatus: display(detail.restoreStatus),
|
||||
restoreCompletedYmd: display(detail.restoreCompletedYmd),
|
||||
},
|
||||
],
|
||||
[detail],
|
||||
() => {
|
||||
const restoreStatus = String(detail.restoreStatus ?? "").trim();
|
||||
return [
|
||||
{
|
||||
acdntNo: display(detail.acdntNo),
|
||||
ocrnYmd: display(detail.ocrnYmd),
|
||||
fullAddress: display(detail.fullAddress || detail.daddr),
|
||||
sizeWidth: display(detail.sizeWidth),
|
||||
sizeLength: display(detail.sizeLength),
|
||||
sizeDepth: display(detail.sizeDepth),
|
||||
detailCause: display(detail.detailCause),
|
||||
restoreStatus: display(
|
||||
restoreStatusLabels[restoreStatus] || restoreStatus,
|
||||
),
|
||||
restoreCompletedYmd: display(detail.restoreCompletedYmd),
|
||||
},
|
||||
];
|
||||
},
|
||||
[detail, restoreStatusLabels],
|
||||
);
|
||||
|
||||
const summaryHeadList = [
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type SinkholeSyncSummary,
|
||||
type SinkholeYearRange,
|
||||
} from "./sinkholeTypes";
|
||||
import LayerSettingCard from "../layerSetting/LayerSettingCard";
|
||||
|
||||
type MaybePromise<T = void> = T | Promise<T>;
|
||||
|
||||
@@ -21,6 +22,9 @@ type Props = {
|
||||
lastSyncSummary?: SinkholeSyncSummary | null;
|
||||
isAdmin?: boolean;
|
||||
compact?: boolean;
|
||||
enabled?: boolean;
|
||||
onEnabledChange?: (enabled: boolean) => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export default function SinkholeLayerSettingPanel({
|
||||
@@ -31,6 +35,9 @@ export default function SinkholeLayerSettingPanel({
|
||||
lastSyncSummary,
|
||||
isAdmin = false,
|
||||
compact = false,
|
||||
enabled = true,
|
||||
onEnabledChange = () => {},
|
||||
onClose = () => {},
|
||||
}: Props) {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
@@ -94,7 +101,7 @@ export default function SinkholeLayerSettingPanel({
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearSelectClass =
|
||||
"h-[22px] w-[62px] rounded-none border border-input bg-background px-[4px] text-center text-[1rem] leading-none text-foreground";
|
||||
"h-[20px] !w-[58px] !rounded border border-input bg-background px-[1px] text-center text-[1.1rem] leading-none text-foreground";
|
||||
const startYearOptions = Array.from(
|
||||
{ length: currentYear - SINKHOLE_MIN_SEARCH_YEAR + 1 },
|
||||
(_, index) => String(currentYear - index),
|
||||
@@ -117,17 +124,17 @@ export default function SinkholeLayerSettingPanel({
|
||||
.map((year) => ({ val: year, text: year }));
|
||||
|
||||
const inputClass =
|
||||
"h-[22px] border border-input bg-background px-[2px] text-center text-[1rem] leading-none text-foreground";
|
||||
"h-[20px] !rounded border border-input bg-background px-[1px] text-center text-[1.1rem] leading-none text-foreground";
|
||||
const numberInputClass = inputClass + " w-[40px]";
|
||||
const applyButtonClass =
|
||||
"grid h-[20px] w-[20px] shrink-0 place-items-center rounded-full bg-primary text-[0.95rem] font-bold leading-none text-primary-foreground";
|
||||
"grid h-[16px] w-[16px] shrink-0 place-items-center rounded-full bg-primary text-[0.85rem] font-bold leading-none text-primary-foreground";
|
||||
const indicator = (
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
);
|
||||
|
||||
const settingRows = (
|
||||
<div className="space-y-1.5 text-[1.05rem] font-medium text-label">
|
||||
<div className="flex min-h-[25px] items-center gap-1.5 whitespace-nowrap">
|
||||
<div className="space-y-1.5 text-[1.15rem] font-medium text-label">
|
||||
<div className="flex min-h-[26px] items-center gap-1.5 whitespace-nowrap">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{indicator}
|
||||
<label htmlFor="sinkhole-radius" className="shrink-0">
|
||||
@@ -158,15 +165,15 @@ export default function SinkholeLayerSettingPanel({
|
||||
title="적용"
|
||||
aria-label="반경 적용"
|
||||
>
|
||||
✓
|
||||
<span className="relative left-[1px]">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-[25px] items-center gap-1.5 whitespace-nowrap">
|
||||
<div className="flex min-h-[26px] items-center gap-1.5 whitespace-nowrap">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{indicator}
|
||||
<label htmlFor="sinkhole-start-year" className="shrink-0">
|
||||
년도설정
|
||||
발생년도
|
||||
</label>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
@@ -208,7 +215,7 @@ export default function SinkholeLayerSettingPanel({
|
||||
title="적용"
|
||||
aria-label="연도범위 적용"
|
||||
>
|
||||
✓
|
||||
<span className="relative left-[1px]">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,9 +224,14 @@ export default function SinkholeLayerSettingPanel({
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="w-full rounded-[6px] border border-border bg-background px-2 py-[10px] shadow-blur">
|
||||
<LayerSettingCard
|
||||
title="지반침하정보"
|
||||
enabled={enabled}
|
||||
onEnabledChange={onEnabledChange}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="overflow-visible">{settingRows}</div>
|
||||
</div>
|
||||
</LayerSettingCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,14 @@ import {
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
import useStore from "@/store/useStore";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import PopupSearchPanel, {
|
||||
PopupSearchActions,
|
||||
PopupSearchField,
|
||||
} from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import type { SelectDataType } from "@common/business_components";
|
||||
import {
|
||||
buildSinkholeDefaultFilters,
|
||||
@@ -21,15 +25,14 @@ import {
|
||||
formatDateInputToYmd,
|
||||
formatYmdToDateInput,
|
||||
normalizeSinkholeFilters,
|
||||
SINKHOLE_COORD_STATUS_OPTIONS,
|
||||
SINKHOLE_DOWNLOAD_EXCEL_HEADERS,
|
||||
SINKHOLE_FILTER_SYNC_MESSAGE,
|
||||
SINKHOLE_MIN_SEARCH_YMD,
|
||||
SINKHOLE_RESTORE_STATUS_OPTIONS,
|
||||
SINKHOLE_ROW_SELECT_MESSAGE,
|
||||
type SinkholeFilters,
|
||||
type SinkholeListItem,
|
||||
} from "./sinkholeTypes";
|
||||
import { useSinkholeCommonCodes } from "./useSinkholeCommonCodes";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
const DOWNLOAD_BATCH_SIZE = 2000;
|
||||
@@ -40,6 +43,7 @@ type SinkholeTableRow = SinkholeListItem & {
|
||||
sizeLengthDisplay: string;
|
||||
sizeDepthDisplay: string;
|
||||
detailCauseDisplay: string;
|
||||
restoreStatusDisplay: string;
|
||||
restoreCompletedYmdDisplay: string;
|
||||
coordStatusDisplay: string;
|
||||
};
|
||||
@@ -53,7 +57,7 @@ const HEADLIST: UiHeadProps[] = [
|
||||
{ id: "sizeLengthDisplay", title: "발생규모연장", widthPercent: 9 },
|
||||
{ id: "sizeDepthDisplay", title: "발생규모깊이", widthPercent: 9 },
|
||||
{ id: "detailCauseDisplay", title: "상세발생원인", widthPercent: 11 },
|
||||
{ id: "restoreStatus", title: "복구상태", widthPercent: 8 },
|
||||
{ id: "restoreStatusDisplay", title: "복구상태", widthPercent: 8 },
|
||||
{ id: "coordStatusDisplay", title: "좌표보정상태", widthPercent: 9 },
|
||||
{ id: "restoreCompletedYmdDisplay", title: "복구완료일자", widthPercent: 11 },
|
||||
];
|
||||
@@ -63,10 +67,14 @@ const toDisplay = (value: unknown) => {
|
||||
return normalized ? normalized : "-";
|
||||
};
|
||||
|
||||
const toCoordStatusDisplay = (value: unknown) => {
|
||||
const toCoordStatusDisplay = (
|
||||
value: unknown,
|
||||
labels: Record<string, string> = {},
|
||||
) => {
|
||||
const normalized = String(value ?? "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (labels[normalized]) return labels[normalized];
|
||||
if (normalized === "SUCCESS") return "성공";
|
||||
if (normalized === "NO_ADDRESS") return "주소없음";
|
||||
if (normalized === "FAILED") return "실패";
|
||||
@@ -85,10 +93,29 @@ const toTableRows = (
|
||||
sizeLengthDisplay: toDisplay(item.sizeLength),
|
||||
sizeDepthDisplay: toDisplay(item.sizeDepth),
|
||||
detailCauseDisplay: toDisplay(item.detailCause),
|
||||
restoreStatusDisplay: toDisplay(item.restoreStatus),
|
||||
coordStatusDisplay: toCoordStatusDisplay(item.coordStatus),
|
||||
restoreCompletedYmdDisplay: toDisplay(item.restoreCompletedYmd),
|
||||
}));
|
||||
|
||||
const applyStatusDisplayNames = (
|
||||
rows: SinkholeTableRow[],
|
||||
restoreStatusLabels: Record<string, string>,
|
||||
coordStatusLabels: Record<string, string>,
|
||||
) =>
|
||||
rows.map((row) => {
|
||||
const restoreStatus = String(row.restoreStatus ?? "").trim();
|
||||
return {
|
||||
...row,
|
||||
restoreStatusDisplay:
|
||||
restoreStatusLabels[restoreStatus] || restoreStatus || "-",
|
||||
coordStatusDisplay: toCoordStatusDisplay(
|
||||
row.coordStatus,
|
||||
coordStatusLabels,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const buildDownloadRows = (rows: SinkholeTableRow[]) =>
|
||||
rows.map((row) => ({
|
||||
rowNo: row.rowNo,
|
||||
@@ -98,7 +125,7 @@ const buildDownloadRows = (rows: SinkholeTableRow[]) =>
|
||||
sizeLength: row.sizeLengthDisplay,
|
||||
sizeDepth: row.sizeDepthDisplay,
|
||||
detailCause: row.detailCauseDisplay,
|
||||
restoreStatus: row.restoreStatus || "-",
|
||||
restoreStatus: row.restoreStatusDisplay,
|
||||
restoreCompletedYmd: row.restoreCompletedYmdDisplay,
|
||||
}));
|
||||
|
||||
@@ -107,6 +134,13 @@ const unwrapPayload = <T,>(response: any): T =>
|
||||
|
||||
export default function SinkholePopupPage() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
// 검색 파라미터 계약은 유지하고 선택지·목록 표시명만 CMS 공통코드로 구성한다.
|
||||
const {
|
||||
restoreStatusOptions,
|
||||
coordStatusOptions,
|
||||
restoreStatusLabels,
|
||||
coordStatusLabels,
|
||||
} = useSinkholeCommonCodes();
|
||||
const searchParams = useSearchParams();
|
||||
const hasInitialFilterParams =
|
||||
searchParams.has("startYmd") ||
|
||||
@@ -156,6 +190,10 @@ export default function SinkholePopupPage() {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const displayRows = useMemo(
|
||||
() => applyStatusDisplayNames(rows, restoreStatusLabels, coordStatusLabels),
|
||||
[coordStatusLabels, restoreStatusLabels, rows],
|
||||
);
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
filters.ctpvCd === "0"
|
||||
@@ -274,27 +312,27 @@ export default function SinkholePopupPage() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const normalized = normalizeSinkholeFilters(appliedFilters);
|
||||
const downloadItems: SinkholeListItem[] = [];
|
||||
const totalPages = Math.max(1, Math.ceil(total / DOWNLOAD_BATCH_SIZE));
|
||||
|
||||
for (let page = 1; page <= totalPages; page += 1) {
|
||||
const response = await fetchRows(
|
||||
page,
|
||||
normalized,
|
||||
DOWNLOAD_BATCH_SIZE,
|
||||
{
|
||||
const response = await fetchAllPages({
|
||||
batchSize: DOWNLOAD_BATCH_SIZE,
|
||||
fetchPage: (page, size) =>
|
||||
fetchRows(page + 1, normalized, size, {
|
||||
includeCoordStatus: false,
|
||||
},
|
||||
);
|
||||
downloadItems.push(...response.items);
|
||||
}
|
||||
}),
|
||||
getItems: (page) => page.items,
|
||||
getTotal: (page) => page.total,
|
||||
});
|
||||
const downloadItems: SinkholeListItem[] = response.items;
|
||||
|
||||
if (downloadItems.length === 0) {
|
||||
alert("다운로드할 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadRows = toTableRows(downloadItems, 0, 1);
|
||||
const downloadRows = applyStatusDisplayNames(
|
||||
toTableRows(downloadItems, 0, 1),
|
||||
restoreStatusLabels,
|
||||
coordStatusLabels,
|
||||
);
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: buildDownloadRows(downloadRows) as unknown as JSON[],
|
||||
fileName: `지반침하정보_${new Date().toISOString().slice(0, 10)}.xlsx`,
|
||||
@@ -318,13 +356,10 @@ export default function SinkholePopupPage() {
|
||||
<StandardPopupLayout title="지반침하 목록">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
|
||||
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
|
||||
<label className="block md:max-w-[170px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">
|
||||
발생기간(시작)
|
||||
</div>
|
||||
<PopupSearchPanel>
|
||||
<PopupSearchField label="발생기간(시작)">
|
||||
<Input
|
||||
className="h-[3.2rem] !text-[1.3rem]"
|
||||
className="h-[3.2rem] w-[12rem] !text-[1.3rem]"
|
||||
type="date"
|
||||
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
|
||||
value={formatYmdToDateInput(filters.startYmd)}
|
||||
@@ -335,14 +370,11 @@ export default function SinkholePopupPage() {
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</PopupSearchField>
|
||||
|
||||
<label className="block md:max-w-[170px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">
|
||||
발생기간(종료)
|
||||
</div>
|
||||
<PopupSearchField label="발생기간(종료)">
|
||||
<Input
|
||||
className="h-[3.2rem] !text-[1.3rem]"
|
||||
className="h-[3.2rem] w-[12rem] !text-[1.3rem]"
|
||||
type="date"
|
||||
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
|
||||
value={formatYmdToDateInput(filters.endYmd)}
|
||||
@@ -353,12 +385,11 @@ export default function SinkholePopupPage() {
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</PopupSearchField>
|
||||
|
||||
<label className="block md:max-w-[220px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">시도</div>
|
||||
<PopupSearchField label="시도">
|
||||
<select
|
||||
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
|
||||
className="h-[3.2rem] w-[12rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
|
||||
value={filters.ctpvCd}
|
||||
onChange={(event) =>
|
||||
setFilters({
|
||||
@@ -375,14 +406,11 @@ export default function SinkholePopupPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</PopupSearchField>
|
||||
|
||||
<label className="block md:max-w-[220px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">
|
||||
시군구
|
||||
</div>
|
||||
<PopupSearchField label="시군구">
|
||||
<select
|
||||
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
|
||||
className="h-[3.2rem] w-[12rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
|
||||
value={filters.sggCd}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, sggCd: event.target.value })
|
||||
@@ -395,47 +423,41 @@ export default function SinkholePopupPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</PopupSearchField>
|
||||
|
||||
<label className="block md:max-w-[180px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">
|
||||
복구상태
|
||||
</div>
|
||||
<PopupSearchField label="복구상태">
|
||||
<select
|
||||
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
|
||||
className="h-[3.2rem] w-[10rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
|
||||
value={filters.restoreStatus}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, restoreStatus: event.target.value })
|
||||
}
|
||||
>
|
||||
{SINKHOLE_RESTORE_STATUS_OPTIONS.map((option) => (
|
||||
{restoreStatusOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</PopupSearchField>
|
||||
|
||||
<label className="block md:max-w-[180px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">
|
||||
좌표보정상태
|
||||
</div>
|
||||
<PopupSearchField label="좌표보정상태">
|
||||
<select
|
||||
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
|
||||
className="h-[3.2rem] w-[10rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
|
||||
value={filters.coordStatus}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, coordStatus: event.target.value })
|
||||
}
|
||||
>
|
||||
{SINKHOLE_COORD_STATUS_OPTIONS.map((option) => (
|
||||
{coordStatusOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</PopupSearchField>
|
||||
|
||||
<div className="ml-auto flex flex-wrap justify-end gap-2">
|
||||
<PopupSearchActions>
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
@@ -457,11 +479,11 @@ export default function SinkholePopupPage() {
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</PopupSearchActions>
|
||||
</PopupSearchPanel>
|
||||
|
||||
<MapPopupDataTable
|
||||
rows={rows as Array<SinkholeTableRow & Record<string, unknown>>}
|
||||
rows={displayRows as Array<SinkholeTableRow & Record<string, unknown>>}
|
||||
headlist={HEADLIST}
|
||||
title="지반침하 목록"
|
||||
total={total}
|
||||
@@ -470,6 +492,16 @@ export default function SinkholePopupPage() {
|
||||
onPageChange={(page) => void handlePageChange(page)}
|
||||
onRowClick={handleRowClick}
|
||||
rowId={(row, index) => String(row.fid ?? row.acdntNo ?? index)}
|
||||
renderCell={(row, headId) =>
|
||||
headId === "fullAddress" ? (
|
||||
<span
|
||||
className="block whitespace-nowrap"
|
||||
title={toDisplay(row.fullAddress)}
|
||||
>
|
||||
{toDisplay(row.fullAddress)}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="150rem"
|
||||
actions={
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { getCommonCodes } from "@/components/lib/common/apiHelpers";
|
||||
|
||||
import {
|
||||
SINKHOLE_COORD_STATUS_OPTIONS,
|
||||
SINKHOLE_RESTORE_STATUS_OPTIONS,
|
||||
} from "./sinkholeTypes";
|
||||
|
||||
type CodeLabelMap = Record<string, string>;
|
||||
type StatusOption = { value: string; label: string };
|
||||
|
||||
const COMMON_CODE_GROUPS = {
|
||||
restoreStatus: "SNKHLRSTRSTTS",
|
||||
coordStatus: "SNKHLCRDSTTS",
|
||||
} as const;
|
||||
|
||||
const cachedCodeLists = new Map<
|
||||
string,
|
||||
Array<{ lwpoCd: string; lwpoCdNm: string }>
|
||||
>();
|
||||
const pendingCodeLists = new Map<
|
||||
string,
|
||||
Promise<Array<{ lwpoCd: string; lwpoCdNm: string }>>
|
||||
>();
|
||||
|
||||
/** 같은 브라우저 세션에서는 지반침하 상태 코드그룹별 CMS 요청을 한 번만 수행한다. */
|
||||
const loadCodeList = async (upCd: string) => {
|
||||
const cached = cachedCodeLists.get(upCd);
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = pendingCodeLists.get(upCd);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = getCommonCodes(upCd)
|
||||
.then((codes) => {
|
||||
const codeList = codes.map(({ lwpoCd, lwpoCdNm }) => ({
|
||||
lwpoCd,
|
||||
lwpoCdNm,
|
||||
}));
|
||||
cachedCodeLists.set(upCd, codeList);
|
||||
return codeList;
|
||||
})
|
||||
.finally(() => {
|
||||
pendingCodeLists.delete(upCd);
|
||||
});
|
||||
|
||||
pendingCodeLists.set(upCd, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
/** 지반침하 상태의 검색값 계약은 유지하고 선택지와 표시명만 CMS 기준으로 제공한다. */
|
||||
export const useSinkholeCommonCodes = () => {
|
||||
const [restoreCodes, setRestoreCodes] = useState(
|
||||
cachedCodeLists.get(COMMON_CODE_GROUPS.restoreStatus) ?? [],
|
||||
);
|
||||
const [coordCodes, setCoordCodes] = useState(
|
||||
cachedCodeLists.get(COMMON_CODE_GROUPS.coordStatus) ?? [],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
loadCodeList(COMMON_CODE_GROUPS.restoreStatus)
|
||||
.then((codes) => {
|
||||
if (!cancelled) setRestoreCodes(codes);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("SNKHLRSTRSTTS 공통코드 조회에 실패했습니다.", error);
|
||||
});
|
||||
loadCodeList(COMMON_CODE_GROUPS.coordStatus)
|
||||
.then((codes) => {
|
||||
if (!cancelled) setCoordCodes(codes);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("SNKHLCRDSTTS 공통코드 조회에 실패했습니다.", error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const restoreStatusOptions = useMemo<StatusOption[]>(
|
||||
() =>
|
||||
restoreCodes.length > 0
|
||||
? [
|
||||
{ value: "0", label: "전체" },
|
||||
...restoreCodes.map((code) => ({
|
||||
// 백엔드는 복구상태 한글 설명값을 검색조건으로 사용하므로 코드명을 전송한다.
|
||||
value: code.lwpoCdNm,
|
||||
label: code.lwpoCdNm,
|
||||
})),
|
||||
]
|
||||
: [...SINKHOLE_RESTORE_STATUS_OPTIONS],
|
||||
[restoreCodes],
|
||||
);
|
||||
|
||||
const coordStatusOptions = useMemo<StatusOption[]>(
|
||||
() =>
|
||||
coordCodes.length > 0
|
||||
? [
|
||||
{ value: "0", label: "전체" },
|
||||
...coordCodes.map((code) => ({
|
||||
value: code.lwpoCd,
|
||||
label: code.lwpoCdNm,
|
||||
})),
|
||||
]
|
||||
: [...SINKHOLE_COORD_STATUS_OPTIONS],
|
||||
[coordCodes],
|
||||
);
|
||||
|
||||
const restoreStatusLabels = useMemo<CodeLabelMap>(() => {
|
||||
const labels: CodeLabelMap = {};
|
||||
restoreCodes.forEach((code) => {
|
||||
labels[code.lwpoCd] = code.lwpoCdNm;
|
||||
labels[code.lwpoCdNm] = code.lwpoCdNm;
|
||||
});
|
||||
return labels;
|
||||
}, [restoreCodes]);
|
||||
|
||||
const coordStatusLabels = useMemo<CodeLabelMap>(() => {
|
||||
const labels: CodeLabelMap = {};
|
||||
coordCodes.forEach((code) => {
|
||||
labels[code.lwpoCd] = code.lwpoCdNm;
|
||||
});
|
||||
return labels;
|
||||
}, [coordCodes]);
|
||||
|
||||
return {
|
||||
restoreStatusOptions,
|
||||
coordStatusOptions,
|
||||
restoreStatusLabels,
|
||||
coordStatusLabels,
|
||||
};
|
||||
};
|
||||
@@ -174,9 +174,16 @@ const postMessageToOpener = (
|
||||
const getRiskBadgeClassName = (riskStatus: "DANGER" | "NORMAL") =>
|
||||
riskStatus === "DANGER" ? "sn-popup-badge-danger" : "sn-popup-badge-normal";
|
||||
|
||||
/** 날짜까지 포함해 과거 이력에서도 결과 행 선택을 유일하게 식별한다. */
|
||||
/** 동일 날짜·지역에 여러 강우이벤트가 있어도 실행별 행 키가 겹치지 않게 식별한다. */
|
||||
const buildResultRowKey = (row: WaterRiskResultRow) =>
|
||||
`${row.analysisDate ?? ""}:${row.ctpvCd ?? ""}:${row.sggCd ?? ""}:${row.areaId}`;
|
||||
[
|
||||
row.analysisDate ?? "",
|
||||
row.rainStartedAt ?? "",
|
||||
row.analysisBaseAt ?? "",
|
||||
row.ctpvCd ?? "",
|
||||
row.sggCd ?? "",
|
||||
row.areaId,
|
||||
].join(":");
|
||||
|
||||
/** 실제 통수위험 API를 조회하고 선택 결과를 부모 지도에 전달하는 팝업이다. */
|
||||
export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
@@ -433,6 +440,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
const handleChangeResultMode = (mode: WaterRiskResultMode) => {
|
||||
setResultMode(mode);
|
||||
setResultCurrentPage(1);
|
||||
setResultSort(undefined);
|
||||
setResultPage(EMPTY_RESULT_PAGE);
|
||||
setSelectedResultKey("");
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,8 @@ export const BASIC_TYPES = [
|
||||
{ val: "tb_ms_swage_ppl", text: "하수관로" },
|
||||
{ val: "tb_il_swage_prcs_fclt", text: "하수처리시설" },
|
||||
{ val: "tb_ms_swage_prcs_zone", text: "하수처리구역" },
|
||||
{ val: "tb_ms_fldng_wtlv_map", text: "침수흔적도" },
|
||||
{ val: "tb_ms_fldng_wtlv_map", text: "침수흔적위선" },
|
||||
{ val: "tb_ms_fldng_dpth_map", text: "침수흔적심선" },
|
||||
{ val: "tb_ms_swage_mhl", text: "하수맨홀" },
|
||||
{ val: "tb_ms_swage_pmstn", text: "하수펌프장" },
|
||||
{ val: "tb_ms_stofcbr", text: "우수토실" },
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* - 관망도 시설물 목록 조회: GET /api/v1/fclts (sewerage-service)
|
||||
*/
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
import {
|
||||
MS_SWAGE_PPL_USE_CODES,
|
||||
MS_SWAGE_PPL_CODES,
|
||||
@@ -50,6 +51,9 @@ const isPopupWindow = () =>
|
||||
!!window.opener &&
|
||||
window.location.pathname.startsWith("/popup/");
|
||||
|
||||
// Excel 열에 포함되지 않는 화면용 유하방향 보강은 제외하고 서버가 일괄 병합할 수 있는 크기로 순회한다.
|
||||
const BASIC_EXCEL_BATCH_SIZE = 2_000;
|
||||
|
||||
export default function useBasicTab() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { confirm } = useConfirm();
|
||||
@@ -64,6 +68,7 @@ export default function useBasicTab() {
|
||||
setTableData,
|
||||
setTableCount,
|
||||
setIsLoading,
|
||||
setAppliedBasicQuery,
|
||||
} = useSearchModalStore();
|
||||
const { ctpv, sgg } = useStore();
|
||||
|
||||
@@ -168,6 +173,12 @@ export default function useBasicTab() {
|
||||
|
||||
setTableData(dataWithRowNumber);
|
||||
setTableCount(totalCount);
|
||||
queryParams.delete("page");
|
||||
queryParams.delete("size");
|
||||
setAppliedBasicQuery({
|
||||
basicType,
|
||||
query: queryParams.toString(),
|
||||
});
|
||||
};
|
||||
|
||||
// 기본정보 테이블 cellClick 핸들러
|
||||
@@ -220,73 +231,77 @@ export default function useBasicTab() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const queryParams = new URLSearchParams({
|
||||
lyrTypeNm: basicType,
|
||||
size: "10000",
|
||||
});
|
||||
if (basicType === "tb_ms_swage_ppl")
|
||||
queryParams.set("includeFlowDirectionBasis", "true");
|
||||
const appliedQuery = useSearchModalStore.getState().appliedBasicQuery;
|
||||
if (!appliedQuery) {
|
||||
throw new Error(
|
||||
"화면 조회조건을 확인할 수 없습니다. 다시 조회해주세요.",
|
||||
);
|
||||
}
|
||||
const appliedBasicType = appliedQuery.basicType;
|
||||
const queryParams = new URLSearchParams(appliedQuery.query);
|
||||
if (appliedBasicType === "tb_ms_swage_ppl") {
|
||||
queryParams.delete("includeFlowDirectionBasis");
|
||||
}
|
||||
|
||||
let cqlFilters: string[] = [];
|
||||
let headerMapping: { [key: string]: string } = {};
|
||||
let headerRange: string = "A1:F1";
|
||||
|
||||
switch (basicType) {
|
||||
switch (appliedBasicType) {
|
||||
case "tb_ms_swage_ppl": {
|
||||
// 하수관로
|
||||
cqlFilters = swagePplCqlFilters();
|
||||
headerMapping = MsSwagePplExcelCol;
|
||||
headerRange = "A1:Q1";
|
||||
break;
|
||||
}
|
||||
case "tb_il_swage_prcs_fclt": {
|
||||
// 하수처리시설
|
||||
cqlFilters = swagePrcsFcltCqlFilters();
|
||||
headerMapping = SwtrmtFcltExcelCol;
|
||||
headerRange = "A1:J1";
|
||||
break;
|
||||
}
|
||||
case "tb_ms_swage_prcs_zone": {
|
||||
// 하수처리구역
|
||||
cqlFilters = swagePrcsZoneCqlFilters();
|
||||
headerMapping = SwtrmtPrcsZoneExcelCol;
|
||||
headerRange = "A1:D1";
|
||||
break;
|
||||
}
|
||||
case "tb_ms_fldng_wtlv_map": {
|
||||
// 침수흔적도
|
||||
cqlFilters = fldngWtlvMapCqlFilters();
|
||||
headerMapping = FldngWtlvMapExcelCol;
|
||||
headerRange = "A1:O1";
|
||||
break;
|
||||
}
|
||||
case "tb_ms_swage_mhl": {
|
||||
// 하수맨홀
|
||||
cqlFilters = swageMhlCqlFilters();
|
||||
headerMapping = MsSwageMhlExcelCol;
|
||||
headerRange = "A1:J1";
|
||||
break;
|
||||
}
|
||||
case "tb_ms_swage_pmstn": {
|
||||
// 하수펌프장
|
||||
cqlFilters = swagePmstnCqlFilters();
|
||||
headerMapping = ExclnSewagePumpExcelCol;
|
||||
headerRange = "A1:J1";
|
||||
break;
|
||||
}
|
||||
case "tb_ms_stofcbr": {
|
||||
// 우수토실
|
||||
cqlFilters = stofcbrCqlFilters();
|
||||
headerMapping = stofcbrExcelCol;
|
||||
headerRange = "A1:H1";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (cqlFilters.length > 0)
|
||||
queryParams.append("cqlFiter", cqlFilters.join(" and "));
|
||||
|
||||
const res = await getData(`v1/fclts?${queryParams.toString()}`);
|
||||
const res = await fetchAllPages<any, any>({
|
||||
batchSize: BASIC_EXCEL_BATCH_SIZE,
|
||||
fetchPage: (page, size) => {
|
||||
const pageParams = new URLSearchParams(queryParams);
|
||||
pageParams.set("page", String(page));
|
||||
pageParams.set("size", String(size));
|
||||
return getData(`v1/fclts?${pageParams.toString()}`);
|
||||
},
|
||||
getItems: (page) => (Array.isArray(page?.data) ? page.data : []),
|
||||
getTotal: (page) => Number(page?.count ?? 0),
|
||||
});
|
||||
|
||||
const mhlUsgCdMap = new Map(
|
||||
MS_SWAGE_MHL_USE_CODES.map((code) => [code.val?.trim(), code.text]),
|
||||
@@ -307,7 +322,7 @@ export default function useBasicTab() {
|
||||
sgg.map((code) => [code.val?.trim(), code.text]),
|
||||
);
|
||||
|
||||
let mappedContent = (res.data || []).map((item: any) => {
|
||||
let mappedContent = res.items.map((item: any) => {
|
||||
const formattedItem = formatDisplayData(item);
|
||||
|
||||
let ctpvCd = item?.ctpvCd ?? formattedItem?.ctpvCd ?? "";
|
||||
@@ -329,7 +344,7 @@ export default function useBasicTab() {
|
||||
const trimmedUsgCd = item?.usgCd?.trim();
|
||||
// const trimmedMqltCd = item?.mqltCd?.trim();
|
||||
|
||||
switch (basicType) {
|
||||
switch (appliedBasicType) {
|
||||
case "tb_ms_swage_ppl": {
|
||||
// 하수관로
|
||||
return {
|
||||
@@ -483,11 +498,13 @@ export default function useBasicTab() {
|
||||
const excelData = mappedContent; // 배열로 감싸서 전달
|
||||
|
||||
// 파일명 생성 (분석번호 기반)
|
||||
const label = BASIC_TYPES.find((type) => type.val === basicType)?.text;
|
||||
const label = BASIC_TYPES.find(
|
||||
(type) => type.val === appliedBasicType,
|
||||
)?.text;
|
||||
const fileName = `${label} 조회 결과_${todayYear}${todayMonth}${todayDate}`;
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: excelData as JSON[],
|
||||
data: excelData as unknown as JSON[],
|
||||
fileName: fileName,
|
||||
sheetNameArray: [fileName],
|
||||
headerRange: headerRange,
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* - 유지관리 정보(사업정보) 관련 시설 목록 조회: GET /api/v1/bizInfo/fclts (sewerage-service)
|
||||
*/
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
import {
|
||||
MS_SWAGE_PPL_CODES,
|
||||
MS_SWAGE_PPL_USE_CODES,
|
||||
@@ -28,6 +29,7 @@ import useSearchModalStore from "@/store/useSearchModalStore";
|
||||
import useStore from "@/store/useStore";
|
||||
import { postPopupMessageAndClose } from "@/features/windowPopup/lib/popupMessage";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
||||
import { useRef } from "react";
|
||||
|
||||
const isPopupWindow = () =>
|
||||
typeof window !== "undefined" &&
|
||||
@@ -73,6 +75,15 @@ export default function useOperationTab() {
|
||||
{ val: "['100']", text: "점검 및 진단" },
|
||||
{ val: "['203']", text: "청소 및 준설" },
|
||||
];
|
||||
// 화면 사업 목록과 결과 목록을 마지막으로 만든 요청을 보관해 Excel·페이지 이동에 재사용한다.
|
||||
const appliedOperationQueryRef = useRef<{
|
||||
query: string;
|
||||
workType: string;
|
||||
} | null>(null);
|
||||
const appliedOperationDetailQueryRef = useRef<{
|
||||
query: string;
|
||||
workType: string;
|
||||
} | null>(null);
|
||||
const OPERATION_TABLE_HEADLIST = [
|
||||
// 유지관리 정보 - 사업 현황
|
||||
{ id: "rowNumber", title: "순번" },
|
||||
@@ -173,6 +184,14 @@ export default function useOperationTab() {
|
||||
setTableCount(totalElements);
|
||||
setDetailTableData([]);
|
||||
setDetailTableCount(0);
|
||||
const appliedParams = new URLSearchParams(params);
|
||||
appliedParams.delete("page");
|
||||
appliedParams.delete("size");
|
||||
appliedOperationQueryRef.current = {
|
||||
query: appliedParams.toString(),
|
||||
workType,
|
||||
};
|
||||
appliedOperationDetailQueryRef.current = null;
|
||||
};
|
||||
|
||||
// 유지관리 정보 테이블 cellClick 핸들러
|
||||
@@ -196,9 +215,9 @@ export default function useOperationTab() {
|
||||
};
|
||||
|
||||
RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"'));
|
||||
|
||||
const requestParams = new URLSearchParams(RequestDTO);
|
||||
const res = await getData(
|
||||
`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`,
|
||||
`v1/bizInfo/fclts?${requestParams.toString()}`,
|
||||
);
|
||||
const totalDetailElements = Number(res.count ?? 0);
|
||||
|
||||
@@ -224,6 +243,12 @@ export default function useOperationTab() {
|
||||
|
||||
setDetailTableData(mappedData);
|
||||
setDetailTableCount(totalDetailElements);
|
||||
requestParams.delete("page");
|
||||
requestParams.delete("size");
|
||||
appliedOperationDetailQueryRef.current = {
|
||||
query: requestParams.toString(),
|
||||
workType,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("유지관리 정보 cellClick 에러:", error);
|
||||
setDetailTableData([]);
|
||||
@@ -272,13 +297,18 @@ export default function useOperationTab() {
|
||||
if (typeof index === "number") {
|
||||
setIsDetailLoading(true);
|
||||
try {
|
||||
const RequestDTO: Record<string, string> = {
|
||||
page: `${detailCurPage - 1}`,
|
||||
size: `${OPERATION_TABLE_SIZE}`,
|
||||
bizId: String(detailBizId),
|
||||
};
|
||||
const appliedDetail = appliedOperationDetailQueryRef.current;
|
||||
const requestParams = new URLSearchParams(
|
||||
appliedDetail?.query ??
|
||||
new URLSearchParams({
|
||||
bizId: String(detailBizId),
|
||||
workTypes: String(JSON.parse(workType.replace(/'/g, '"'))),
|
||||
}).toString(),
|
||||
);
|
||||
requestParams.set("page", `${detailCurPage - 1}`);
|
||||
requestParams.set("size", `${OPERATION_TABLE_SIZE}`);
|
||||
const res = await getData(
|
||||
`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`,
|
||||
`v1/bizInfo/fclts?${requestParams.toString()}`,
|
||||
);
|
||||
const totalElements = Number(res.count ?? 0);
|
||||
|
||||
@@ -335,27 +365,24 @@ export default function useOperationTab() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const RequestDTO: Record<string, string> = {
|
||||
size: "10000",
|
||||
};
|
||||
if (var1) RequestDTO.bizInfoNm = var1;
|
||||
if (var2 != "0" && var2) RequestDTO.bizTypeCd = var2;
|
||||
// 수행기간 범위
|
||||
const startDate = getDateString(yearFilter.start, "", "", "19000101");
|
||||
const endDate = getDateString(
|
||||
yearFilter.end,
|
||||
"",
|
||||
"",
|
||||
`${todayYear}${todayMonth}${todayDate}`,
|
||||
);
|
||||
RequestDTO.fromflfmtEndYmd = startDate;
|
||||
RequestDTO.toflfmtEndYmd = endDate;
|
||||
const appliedQuery = appliedOperationQueryRef.current;
|
||||
if (!appliedQuery) {
|
||||
throw new Error(
|
||||
"화면 조회조건을 확인할 수 없습니다. 다시 조회해주세요.",
|
||||
);
|
||||
}
|
||||
|
||||
// JSON.parse 사용으로 문자열 -> 배열 변환
|
||||
RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"'));
|
||||
|
||||
const params = new URLSearchParams(RequestDTO).toString();
|
||||
const res = await getData(`v1/bizInfo?${params}`);
|
||||
const res = await fetchAllPages<any, any>({
|
||||
fetchPage: (page, size) => {
|
||||
const params = new URLSearchParams(appliedQuery.query);
|
||||
params.set("page", String(page));
|
||||
params.set("size", String(size));
|
||||
return getData(`v1/bizInfo?${params.toString()}`);
|
||||
},
|
||||
getItems: (page) =>
|
||||
Array.isArray(page?.content) ? page.content : [],
|
||||
getTotal: (page) => Number(page?.totalElements ?? 0),
|
||||
});
|
||||
|
||||
// 헤더 매핑 정의
|
||||
const headerMapping: { [key: string]: string } = {
|
||||
@@ -371,7 +398,7 @@ export default function useOperationTab() {
|
||||
BIZ_TY_CD.map((code) => [code.lwpoCd, code.lwpoCdNm]),
|
||||
);
|
||||
|
||||
const mappedContent = (res.content || []).map((item: any) => {
|
||||
const mappedContent = res.items.map((item: any) => {
|
||||
const formattedItem = formatDisplayData(item);
|
||||
|
||||
return {
|
||||
@@ -394,11 +421,13 @@ export default function useOperationTab() {
|
||||
const excelData = mappedContent; // 배열로 감싸서 전달
|
||||
|
||||
// 파일명 생성 (분석번호 기반)
|
||||
const label = WORK_TYPES.find((type) => type.val === workType)?.text;
|
||||
const label = WORK_TYPES.find(
|
||||
(type) => type.val === appliedQuery.workType,
|
||||
)?.text;
|
||||
const fileName = `${label} 사업 현황_${todayYear}${todayMonth}${todayDate}`;
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: excelData as JSON[],
|
||||
data: excelData as unknown as JSON[],
|
||||
fileName: fileName,
|
||||
sheetNameArray: [fileName],
|
||||
headerRange: "A1:F1",
|
||||
@@ -433,14 +462,23 @@ export default function useOperationTab() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const RequestDTO: Record<string, string> = {
|
||||
size: "100",
|
||||
bizId: String(detailBizId),
|
||||
};
|
||||
const appliedQuery = appliedOperationDetailQueryRef.current;
|
||||
if (!appliedQuery) {
|
||||
throw new Error(
|
||||
"화면 결과 조회조건을 확인할 수 없습니다. 다시 조회해주세요.",
|
||||
);
|
||||
}
|
||||
|
||||
const res = await getData(
|
||||
`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`,
|
||||
);
|
||||
const res = await fetchAllPages<any, any>({
|
||||
fetchPage: (page, size) => {
|
||||
const params = new URLSearchParams(appliedQuery.query);
|
||||
params.set("page", String(page));
|
||||
params.set("size", String(size));
|
||||
return getData(`v1/bizInfo/fclts?${params.toString()}`);
|
||||
},
|
||||
getItems: (page) => (Array.isArray(page?.data) ? page.data : []),
|
||||
getTotal: (page) => Number(page?.count ?? 0),
|
||||
});
|
||||
|
||||
// 헤더 매핑 정의
|
||||
const headerMapping: { [key: string]: string } = {
|
||||
@@ -463,7 +501,7 @@ export default function useOperationTab() {
|
||||
);
|
||||
|
||||
// 🎯 단일 반복문으로 최적화 - 각 아이템마다 필요한 필드만 직접 매핑
|
||||
const mappedData = (res.data || []).map((item: any) => {
|
||||
const mappedData = res.items.map((item: any) => {
|
||||
const formattedItem = formatDisplayData(item);
|
||||
|
||||
// trim 한 번만 수행
|
||||
@@ -507,11 +545,13 @@ export default function useOperationTab() {
|
||||
const excelData = mappedData; // 배열로 감싸서 전달
|
||||
|
||||
// 파일명 생성 (분석번호 기반)
|
||||
const label = WORK_TYPES.find((type) => type.val === workType)?.text;
|
||||
const label = WORK_TYPES.find(
|
||||
(type) => type.val === appliedQuery.workType,
|
||||
)?.text;
|
||||
const fileName = `${label} 결과표_${todayYear}${todayMonth}${todayDate}`;
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: excelData as JSON[],
|
||||
data: excelData as unknown as JSON[],
|
||||
fileName: fileName,
|
||||
sheetNameArray: [fileName],
|
||||
headerRange: "A1:I1",
|
||||
|
||||
@@ -89,7 +89,9 @@ const hasLegendStyle = (layerInfo: any) =>
|
||||
|
||||
const normalizeLegendLabel = (label: string) => {
|
||||
const normalized = String(label).trim();
|
||||
return normalized.endsWith("미만") ? normalized : `${normalized} 미만`;
|
||||
return /(?:미만|이상)$/.test(normalized)
|
||||
? normalized
|
||||
: `${normalized} 미만`;
|
||||
};
|
||||
|
||||
export const extractWaterDepthLegendItems = (
|
||||
|
||||
@@ -109,6 +109,7 @@ const BaseLayout = ({
|
||||
onDeleteFile,
|
||||
onAnalysis,
|
||||
onResultView,
|
||||
selectedRowData,
|
||||
isIr = false,
|
||||
isRdii = false,
|
||||
}: BaseLayout) => {
|
||||
@@ -283,11 +284,7 @@ const BaseLayout = ({
|
||||
</Button>
|
||||
)}
|
||||
{resultViewBtn && (
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
onClick={onResultView}
|
||||
>
|
||||
<Button type="button" color="white" onClick={onResultView}>
|
||||
{resultViewBtn}
|
||||
</Button>
|
||||
)}
|
||||
@@ -295,6 +292,7 @@ const BaseLayout = ({
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
disabled={!selectedRowData}
|
||||
onClick={isDeleteFile}
|
||||
>
|
||||
삭제
|
||||
@@ -322,13 +320,18 @@ const BaseLayout = ({
|
||||
</h3>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{graphBtn && (
|
||||
<Button type="button">
|
||||
<Button type="button" onClick={isChckFile}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
)}
|
||||
{/* 그래프 있을 시 */}
|
||||
{graphBtn && (
|
||||
<Button type="button" color="red">
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
disabled={!selectedRowData}
|
||||
onClick={isDeleteFile}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"use client";
|
||||
import { deleteData, getData } from "@/components/lib/api";
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -239,12 +240,18 @@ const Result = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getData(
|
||||
`v1/rdiiAnlsRslt/ppl/${selectedRowData.rainInfwtAnlsSn}`,
|
||||
);
|
||||
const data = await fetchAllPages<any, Record<string, unknown>>({
|
||||
fetchPage: (page, size) =>
|
||||
getData(
|
||||
`v1/rdiiAnlsRslt/ppl/${selectedRowData.rainInfwtAnlsSn}?page=${page}&size=${size}`,
|
||||
),
|
||||
getItems: (page) =>
|
||||
Array.isArray(page?.content) ? page.content : [],
|
||||
getTotal: (page) => Number(page?.totalElements ?? 0),
|
||||
});
|
||||
|
||||
// 선택된 행 데이터를 Excel 다운로드 형식으로 변환
|
||||
const excelData = data.content; // 배열로 감싸서 전달
|
||||
// 선택된 분석의 전체 관로 결과를 Excel 다운로드 형식으로 변환한다.
|
||||
const excelData = data.items;
|
||||
|
||||
// 헤더 매핑 정의
|
||||
const headerMapping: { [key: string]: string } = {
|
||||
@@ -258,7 +265,7 @@ const Result = () => {
|
||||
const fileName = `RDII분석결과_${selectedRowData.swtrmtFcltNm}_${selectedRowData.aplcnYm}_${selectedRowData.rainInfwtAnlsSn}`;
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: excelData as JSON[],
|
||||
data: excelData as unknown as JSON[],
|
||||
fileName: fileName,
|
||||
sheetNameArray: ["RDII 분석결과"],
|
||||
headerRange: "A1:D1",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"use client";
|
||||
import { deleteData, getData } from "@/components/lib/api";
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -239,12 +240,18 @@ const Result = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getData(
|
||||
`v1/inftwtAnlsRslt/ppl/${selectedRowData.inftwtAnlsSn}`,
|
||||
);
|
||||
const data = await fetchAllPages<any, Record<string, unknown>>({
|
||||
fetchPage: (page, size) =>
|
||||
getData(
|
||||
`v1/inftwtAnlsRslt/ppl/${selectedRowData.inftwtAnlsSn}?page=${page}&size=${size}`,
|
||||
),
|
||||
getItems: (page) =>
|
||||
Array.isArray(page?.content) ? page.content : [],
|
||||
getTotal: (page) => Number(page?.totalElements ?? 0),
|
||||
});
|
||||
|
||||
// 선택된 행 데이터를 Excel 다운로드 형식으로 변환
|
||||
const excelData = data.content; // 배열로 감싸서 전달
|
||||
// 선택된 분석의 전체 관로 결과를 Excel 다운로드 형식으로 변환한다.
|
||||
const excelData = data.items;
|
||||
|
||||
// 헤더 매핑 정의
|
||||
const headerMapping: { [key: string]: string } = {
|
||||
@@ -258,7 +265,7 @@ const Result = () => {
|
||||
const fileName = `침입수분석결과_${selectedRowData.swtrmtFcltNm}_${selectedRowData.aplcnYm}_${selectedRowData.inftwtAnlsSn}`;
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: excelData as JSON[],
|
||||
data: excelData as unknown as JSON[],
|
||||
fileName: fileName,
|
||||
sheetNameArray: ["침입수 분석결과"],
|
||||
headerRange: "A1:D1",
|
||||
|
||||
+15
-11
@@ -25,7 +25,10 @@ import {
|
||||
uploadDredgingPhotoDrafts,
|
||||
type AttachmentReference,
|
||||
} from "../lib/client";
|
||||
import { formatDredgingCapturedDate } from "../lib/date";
|
||||
import { readPhotoMetadata } from "../lib/exif";
|
||||
import { useDredgingCommonCodes } from "../lib/useDredgingCommonCodes";
|
||||
import { createDredgingClientUuid } from "../lib/uuid";
|
||||
import {
|
||||
EMPTY_PHOTO_METADATA,
|
||||
type DredgingPhoto,
|
||||
@@ -67,11 +70,6 @@ const resolvePipeMaterialName = (value: string) => {
|
||||
return pipeMaterialNameByCode.get(normalizedCode) ?? "확인불가";
|
||||
};
|
||||
|
||||
const locationSourceLabel = {
|
||||
EXIF: "사진 GPS",
|
||||
MANUAL: "지도 지정",
|
||||
} as const;
|
||||
|
||||
const formatLocalDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
@@ -89,6 +87,9 @@ const DredgingDetailPopupPage = ({
|
||||
}: Props) => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { confirm } = useConfirm();
|
||||
// 수정 화면도 등록 화면과 같은 CMS 공통코드 표시명을 공유한다.
|
||||
const { registrationModeLabels, locationSourceLabels } =
|
||||
useDredgingCommonCodes();
|
||||
const editable = mode === "edit";
|
||||
const [photos, setPhotos] = useState<DredgingPhotoPreview[]>([]);
|
||||
const [newPhotos, setNewPhotos] = useState<NewPhotoPreview[]>([]);
|
||||
@@ -235,7 +236,7 @@ const DredgingDetailPopupPage = ({
|
||||
? `${matchedPipe.installationYear}년`
|
||||
: "확인불가",
|
||||
],
|
||||
["위치등록", locationSourceLabel[locationSource]],
|
||||
["위치등록", locationSourceLabels[locationSource] || locationSource],
|
||||
];
|
||||
const pipeInfoRows = Array.from(
|
||||
{ length: Math.ceil(pipeInfo.length / 2) },
|
||||
@@ -259,7 +260,7 @@ const DredgingDetailPopupPage = ({
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
newPhotoUrlsRef.current.push(previewUrl);
|
||||
return {
|
||||
key: crypto.randomUUID(),
|
||||
key: createDredgingClientUuid(),
|
||||
file,
|
||||
metadata: EMPTY_PHOTO_METADATA,
|
||||
previewUrl,
|
||||
@@ -574,7 +575,7 @@ const DredgingDetailPopupPage = ({
|
||||
onClick={() => void changeRegistrationMode("PHOTO")}
|
||||
data-testid="dredging-edit-photo-mode"
|
||||
>
|
||||
사진 기반 등록
|
||||
{registrationModeLabels.PHOTO}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -586,7 +587,7 @@ const DredgingDetailPopupPage = ({
|
||||
onClick={() => void changeRegistrationMode("MANUAL")}
|
||||
data-testid="dredging-edit-manual-mode"
|
||||
>
|
||||
사진 없이 직접 등록
|
||||
{registrationModeLabels.MANUAL}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
@@ -657,7 +658,8 @@ const DredgingDetailPopupPage = ({
|
||||
`준설사진 ${index + 1}`}
|
||||
</strong>
|
||||
<span className="shrink-0 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{photo.capturedAt ?? "촬영일시 없음"}
|
||||
{formatDredgingCapturedDate(photo.capturedAt) ??
|
||||
"촬영일시 없음"}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
@@ -704,7 +706,9 @@ const DredgingDetailPopupPage = ({
|
||||
신규 · {photo.file.name}
|
||||
</strong>
|
||||
<span className="shrink-0 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{photo.metadata.capturedAt ?? "촬영일시 없음"}
|
||||
{formatDredgingCapturedDate(
|
||||
photo.metadata.capturedAt,
|
||||
) ?? "촬영일시 없음"}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
@@ -29,7 +29,10 @@ import {
|
||||
type AttachmentReference,
|
||||
uploadDredgingPhotos,
|
||||
} from "../lib/client";
|
||||
import { formatDredgingCapturedDate } from "../lib/date";
|
||||
import { readPhotoMetadata } from "../lib/exif";
|
||||
import { useDredgingCommonCodes } from "../lib/useDredgingCommonCodes";
|
||||
import { createDredgingClientUuid } from "../lib/uuid";
|
||||
import {
|
||||
EMPTY_PHOTO_METADATA,
|
||||
type DredgingDraftGroup,
|
||||
@@ -51,7 +54,7 @@ type DredgingRegisterModalProps = {
|
||||
};
|
||||
|
||||
const createGroup = (): DredgingDraftGroup => ({
|
||||
id: crypto.randomUUID(),
|
||||
id: createDredgingClientUuid(),
|
||||
registrationMode: "PHOTO",
|
||||
photos: [],
|
||||
manualLocation: null,
|
||||
@@ -72,14 +75,6 @@ const getResolvedLocation = (group: DredgingDraftGroup): GeoPoint | null => {
|
||||
);
|
||||
};
|
||||
|
||||
const statusLabel = (status: DredgingPhotoDraft["metadata"]["status"]) => {
|
||||
if (status === "COMPLETE") return "정상";
|
||||
if (status === "MISSING_DATE") return "촬영일 없음";
|
||||
if (status === "MISSING_GPS") return "GPS 없음";
|
||||
if (status === "MISSING_BOTH") return "촬영일·GPS 없음";
|
||||
return "수동 입력 필요";
|
||||
};
|
||||
|
||||
const formatLocalDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
@@ -97,6 +92,12 @@ const DredgingRegisterModal = ({
|
||||
}: DredgingRegisterModalProps) => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { confirm } = useConfirm();
|
||||
// 등록방식·위치출처·사진상태 코드값은 유지하고 CMS 코드명을 화면에 표시한다.
|
||||
const {
|
||||
registrationModeLabels,
|
||||
locationSourceLabels,
|
||||
photoStatusLabels,
|
||||
} = useDredgingCommonCodes();
|
||||
const [groups, setGroups] = useState<DredgingDraftGroup[]>([]);
|
||||
const groupsRef = useRef<DredgingDraftGroup[]>([]);
|
||||
const [activeGroupId, setActiveGroupId] = useState<string | null>(null);
|
||||
@@ -253,7 +254,7 @@ const DredgingRegisterModal = ({
|
||||
}
|
||||
|
||||
const checkingPhotos: DredgingPhotoDraft[] = files.map((file) => ({
|
||||
key: crypto.randomUUID(),
|
||||
key: createDredgingClientUuid(),
|
||||
file,
|
||||
metadata: EMPTY_PHOTO_METADATA,
|
||||
}));
|
||||
@@ -704,7 +705,7 @@ const DredgingRegisterModal = ({
|
||||
void changeRegistrationMode(activeGroup.id, "PHOTO")
|
||||
}
|
||||
>
|
||||
사진 기반 등록
|
||||
{registrationModeLabels.PHOTO}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -719,7 +720,7 @@ const DredgingRegisterModal = ({
|
||||
void changeRegistrationMode(activeGroup.id, "MANUAL")
|
||||
}
|
||||
>
|
||||
사진 없이 직접 등록
|
||||
{registrationModeLabels.MANUAL}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -762,9 +763,15 @@ const DredgingRegisterModal = ({
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="truncate px-3"
|
||||
title={photo.metadata.capturedAt ?? "-"}
|
||||
title={
|
||||
formatDredgingCapturedDate(
|
||||
photo.metadata.capturedAt,
|
||||
) ?? "-"
|
||||
}
|
||||
>
|
||||
{photo.metadata.capturedAt ?? "-"}
|
||||
{formatDredgingCapturedDate(
|
||||
photo.metadata.capturedAt,
|
||||
) ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell className="px-3">
|
||||
{photo.metadata.location
|
||||
@@ -779,7 +786,9 @@ const DredgingRegisterModal = ({
|
||||
: "bg-amber-100 text-amber-800"
|
||||
}`}
|
||||
>
|
||||
{statusLabel(photo.metadata.status)}
|
||||
{photoStatusLabels[
|
||||
photo.metadata.status
|
||||
] || "수동 입력 필요"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-2 text-center">
|
||||
@@ -882,9 +891,9 @@ const DredgingRegisterModal = ({
|
||||
{hasUnappliedLocationDraft
|
||||
? "새 위치 선택됨"
|
||||
: activeGroup.manualLocation
|
||||
? "지도 지정 위치"
|
||||
? `${locationSourceLabels.MANUAL} 위치`
|
||||
: activeResolvedLocation
|
||||
? "사진 GPS 위치"
|
||||
? `${locationSourceLabels.EXIF} 위치`
|
||||
: "위치 지정 필요"}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
putData,
|
||||
} from "@/components/lib/api";
|
||||
import { handleUpload } from "@/components/lib/utils";
|
||||
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
import type { HttpResponse } from "tus-js-client";
|
||||
|
||||
import type {
|
||||
@@ -17,6 +18,7 @@ import type {
|
||||
GeoPoint,
|
||||
PipeMatch,
|
||||
} from "./types";
|
||||
import { createDredgingClientUuid } from "./uuid";
|
||||
|
||||
type LocationRequest = GeoPoint & {
|
||||
locationKey: string;
|
||||
@@ -121,6 +123,19 @@ export const fetchDredgingRecords = async (params: {
|
||||
}
|
||||
};
|
||||
|
||||
/** 조회조건에 맞는 준설기록 전체 페이지를 순회해 목록·Excel 건수를 일치시킨다. */
|
||||
export const fetchAllDredgingRecords = async (params: {
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
ctpv?: string;
|
||||
sgg?: string;
|
||||
}) =>
|
||||
fetchAllPages({
|
||||
fetchPage: (page, size) => fetchDredgingRecords({ ...params, page, size }),
|
||||
getItems: (response) => response.items ?? [],
|
||||
getTotal: (response) => Number(response.total ?? 0),
|
||||
});
|
||||
|
||||
/** 등록된 준설사진의 공통 첨부파일 참조를 조회한다. */
|
||||
export const fetchDredgingPhotos = async (
|
||||
dredgingJobSn: string | number,
|
||||
@@ -388,11 +403,11 @@ export const saveDredgingRegistrations = async (
|
||||
return {
|
||||
mock: true,
|
||||
registeredCount: 1,
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
clientRequestId: createDredgingClientUuid(),
|
||||
};
|
||||
}
|
||||
|
||||
const clientRequestId = crypto.randomUUID();
|
||||
const clientRequestId = createDredgingClientUuid();
|
||||
try {
|
||||
return await postRawJson("v1/dredging/registrations", {
|
||||
clientRequestId,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/** 촬영일시 원본은 유지하고 등록·수정 화면 표출값만 YYYY-MM-DD로 정규화한다. */
|
||||
export const formatDredgingCapturedDate = (
|
||||
value: string | null | undefined,
|
||||
) => {
|
||||
const normalized = String(value ?? "").trim();
|
||||
const matched = normalized.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (!matched) return null;
|
||||
|
||||
const year = Number(matched[1]);
|
||||
const month = Number(matched[2]);
|
||||
const day = Number(matched[3]);
|
||||
const date = new Date(year, month - 1, day);
|
||||
if (
|
||||
date.getFullYear() !== year ||
|
||||
date.getMonth() !== month - 1 ||
|
||||
date.getDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return matched[0];
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getCommonCodes } from "@/components/lib/common/apiHelpers";
|
||||
|
||||
import type {
|
||||
DredgingLocationSource,
|
||||
DredgingRegistrationMode,
|
||||
ExifStatus,
|
||||
} from "./types";
|
||||
|
||||
type CodeLabelMap = Record<string, string>;
|
||||
|
||||
const COMMON_CODE_GROUPS = {
|
||||
registrationMode: "DRDGNREGMODE",
|
||||
locationSource: "DRDGNLOCSRC",
|
||||
photoStatus: "DRDGNPHOTOSTTS",
|
||||
} as const;
|
||||
|
||||
const DEFAULT_REGISTRATION_MODE_LABELS: Record<
|
||||
DredgingRegistrationMode,
|
||||
string
|
||||
> = {
|
||||
PHOTO: "사진 기반 등록",
|
||||
MANUAL: "사진 없이 직접 등록",
|
||||
};
|
||||
|
||||
const DEFAULT_LOCATION_SOURCE_LABELS: Record<DredgingLocationSource, string> = {
|
||||
EXIF: "사진 GPS",
|
||||
MANUAL: "지도 지정",
|
||||
};
|
||||
|
||||
const DEFAULT_PHOTO_STATUS_LABELS: Record<ExifStatus, string> = {
|
||||
COMPLETE: "정상",
|
||||
MISSING_DATE: "촬영일 없음",
|
||||
MISSING_GPS: "GPS 없음",
|
||||
MISSING_BOTH: "촬영일·GPS 없음",
|
||||
UNSUPPORTED: "수동 입력 필요",
|
||||
};
|
||||
|
||||
const cachedLabelMaps = new Map<string, CodeLabelMap>();
|
||||
const pendingLabelMaps = new Map<string, Promise<CodeLabelMap>>();
|
||||
|
||||
/** 같은 브라우저 세션에서는 준설 코드그룹별 CMS 요청을 한 번만 수행한다. */
|
||||
const loadCodeLabelMap = async (
|
||||
upCd: string,
|
||||
fallback: CodeLabelMap,
|
||||
): Promise<CodeLabelMap> => {
|
||||
const cached = cachedLabelMaps.get(upCd);
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = pendingLabelMaps.get(upCd);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = getCommonCodes(upCd)
|
||||
.then((codes) => {
|
||||
const labels = { ...fallback };
|
||||
codes.forEach((code) => {
|
||||
if (code.lwpoCd && code.lwpoCdNm) {
|
||||
labels[code.lwpoCd] = code.lwpoCdNm;
|
||||
}
|
||||
});
|
||||
cachedLabelMaps.set(upCd, labels);
|
||||
return labels;
|
||||
})
|
||||
.finally(() => {
|
||||
pendingLabelMaps.delete(upCd);
|
||||
});
|
||||
|
||||
pendingLabelMaps.set(upCd, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
/** 준설 저장 코드값은 유지하고 버튼·상세·사진상태 표시명만 CMS 기준으로 제공한다. */
|
||||
export const useDredgingCommonCodes = () => {
|
||||
const [registrationModeLabels, setRegistrationModeLabels] =
|
||||
useState<CodeLabelMap>(
|
||||
cachedLabelMaps.get(COMMON_CODE_GROUPS.registrationMode) ??
|
||||
DEFAULT_REGISTRATION_MODE_LABELS,
|
||||
);
|
||||
const [locationSourceLabels, setLocationSourceLabels] =
|
||||
useState<CodeLabelMap>(
|
||||
cachedLabelMaps.get(COMMON_CODE_GROUPS.locationSource) ??
|
||||
DEFAULT_LOCATION_SOURCE_LABELS,
|
||||
);
|
||||
const [photoStatusLabels, setPhotoStatusLabels] = useState<CodeLabelMap>(
|
||||
cachedLabelMaps.get(COMMON_CODE_GROUPS.photoStatus) ??
|
||||
DEFAULT_PHOTO_STATUS_LABELS,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const applyLabels = (
|
||||
upCd: string,
|
||||
fallback: CodeLabelMap,
|
||||
setter: (labels: CodeLabelMap) => void,
|
||||
) => {
|
||||
loadCodeLabelMap(upCd, fallback)
|
||||
.then((labels) => {
|
||||
if (!cancelled) setter(labels);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`${upCd} 공통코드 조회에 실패했습니다.`, error);
|
||||
});
|
||||
};
|
||||
|
||||
applyLabels(
|
||||
COMMON_CODE_GROUPS.registrationMode,
|
||||
DEFAULT_REGISTRATION_MODE_LABELS,
|
||||
setRegistrationModeLabels,
|
||||
);
|
||||
applyLabels(
|
||||
COMMON_CODE_GROUPS.locationSource,
|
||||
DEFAULT_LOCATION_SOURCE_LABELS,
|
||||
setLocationSourceLabels,
|
||||
);
|
||||
applyLabels(
|
||||
COMMON_CODE_GROUPS.photoStatus,
|
||||
DEFAULT_PHOTO_STATUS_LABELS,
|
||||
setPhotoStatusLabels,
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
registrationModeLabels,
|
||||
locationSourceLabels,
|
||||
photoStatusLabels,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 일반 HTTP에서는 `crypto.randomUUID`가 제공되지 않을 수 있어 Web Crypto 난수로 UUID v4를 생성한다.
|
||||
* 백엔드 `clientRequestId`가 UUID 타입이므로 fallback도 같은 36자 형식을 유지한다.
|
||||
*/
|
||||
export const createDredgingClientUuid = () => {
|
||||
if (
|
||||
typeof crypto !== "undefined" &&
|
||||
typeof crypto.randomUUID === "function"
|
||||
) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(16);
|
||||
if (
|
||||
typeof crypto !== "undefined" &&
|
||||
typeof crypto.getRandomValues === "function"
|
||||
) {
|
||||
crypto.getRandomValues(bytes);
|
||||
} else {
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
bytes[index] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (value) =>
|
||||
value.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
};
|
||||
@@ -3,13 +3,18 @@
|
||||
*
|
||||
* - SFR-010-012 화면설계서 50~55쪽을 기준으로 한 FE 선행 구현
|
||||
* - 사진 EXIF 촬영일/GPS, 지도 수동 위치, 최근접 관로 자동 매핑 흐름 제공
|
||||
* - 지자체별/관경별/경과년도별 조회와 CSV 다운로드 제공
|
||||
* - 지자체별/관경별/경과년도별 조회와 XLSX 다운로드 제공
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { StandardDataTable } from "@common/business_components";
|
||||
import {
|
||||
StandardDataTable,
|
||||
StandardTableSortHeader,
|
||||
sortStandardTableRows,
|
||||
type UiTableSort,
|
||||
} from "@common/business_components";
|
||||
import {
|
||||
Button,
|
||||
InputDatePicker,
|
||||
@@ -35,9 +40,10 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
|
||||
import {
|
||||
deleteDredgingRecord,
|
||||
fetchDredgingRecords,
|
||||
fetchAllDredgingRecords,
|
||||
isDredgingBackendEnabled,
|
||||
} from "./lib/client";
|
||||
import { createDredgingClientUuid } from "./lib/uuid";
|
||||
import type {
|
||||
DredgingRecord,
|
||||
DredgingSaveGroup,
|
||||
@@ -343,6 +349,8 @@ const Page = () => {
|
||||
// 삭제 중 중복 요청과 선택 변경을 막는다.
|
||||
const [isMutating, setIsMutating] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
// 조회구분별 전체 결과를 페이지 절단 전에 정렬하는 단일 상태다.
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
const [searchVersion, setSearchVersion] = useState(0);
|
||||
|
||||
/** 공통 레이아웃에서 아직 지역 기준정보를 받지 못한 경우 조회를 보완한다. */
|
||||
@@ -368,12 +376,11 @@ const Page = () => {
|
||||
setIsLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const response = await fetchDredgingRecords({
|
||||
const response = await fetchAllDredgingRecords({
|
||||
fromDate: appliedFromDate,
|
||||
toDate: appliedToDate,
|
||||
ctpv: appliedCtpvName,
|
||||
sgg: appliedSggName,
|
||||
size: 2000,
|
||||
});
|
||||
if (!cancelled) setRecords(response.items ?? []);
|
||||
} catch {
|
||||
@@ -431,10 +438,19 @@ const Page = () => {
|
||||
}));
|
||||
}, [filteredRecords, viewType]);
|
||||
|
||||
// 준설 API의 모든 페이지를 수집한 뒤 표시명·집계값 기준으로 정렬한다.
|
||||
const sortedTableRows = useMemo(
|
||||
() => sortStandardTableRows(tableRows, sort),
|
||||
[sort, tableRows],
|
||||
);
|
||||
|
||||
const pageRows = useMemo(
|
||||
() =>
|
||||
tableRows.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE),
|
||||
[tableRows, currentPage],
|
||||
sortedTableRows.slice(
|
||||
(currentPage - 1) * PAGE_SIZE,
|
||||
currentPage * PAGE_SIZE,
|
||||
),
|
||||
[currentPage, sortedTableRows],
|
||||
);
|
||||
|
||||
const selectedRecord =
|
||||
@@ -461,13 +477,24 @@ const Page = () => {
|
||||
headlist.map((head) => ({
|
||||
id: head.id,
|
||||
accessorFn: (row) => row[head.id],
|
||||
header: head.title,
|
||||
header: () => (
|
||||
<StandardTableSortHeader
|
||||
label={head.title}
|
||||
sortKey={head.id}
|
||||
sort={sort}
|
||||
onSortChange={(nextSort) => {
|
||||
setSort(nextSort);
|
||||
setCurrentPage(1);
|
||||
setSelectedRecordId(null);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
cell: ({ getValue }) => formatTableValue(getValue()),
|
||||
meta: {
|
||||
align: head.align ?? "center",
|
||||
},
|
||||
})),
|
||||
[headlist],
|
||||
[headlist, sort],
|
||||
);
|
||||
const pagination: PaginationState = {
|
||||
pageIndex: currentPage - 1,
|
||||
@@ -486,7 +513,7 @@ const Page = () => {
|
||||
pagination,
|
||||
rowSelection: selectedTableRowId ? { [selectedTableRowId]: true } : {},
|
||||
},
|
||||
rowCount: tableRows.length,
|
||||
rowCount: sortedTableRows.length,
|
||||
manualPagination: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row) => row.recordIds.join("|"),
|
||||
@@ -524,6 +551,7 @@ const Page = () => {
|
||||
setSggName("");
|
||||
setAppliedCtpvName("");
|
||||
setAppliedSggName("");
|
||||
setSort(undefined);
|
||||
setCurrentPage(1);
|
||||
setSelectedRecordId(null);
|
||||
setSearchVersion((version) => version + 1);
|
||||
@@ -534,7 +562,7 @@ const Page = () => {
|
||||
const visibleHeads = headlist.filter((head) => head.id !== "recordIds");
|
||||
const excelRows = [
|
||||
visibleHeads.map((head) => head.title),
|
||||
...tableRows.map((row) =>
|
||||
...sortedTableRows.map((row) =>
|
||||
visibleHeads.map((head) => row[head.id] ?? ""),
|
||||
),
|
||||
];
|
||||
@@ -565,7 +593,7 @@ const Page = () => {
|
||||
return;
|
||||
}
|
||||
const registeredDate = formatLocalDate(new Date());
|
||||
const localJobSn = `local-job-${crypto.randomUUID()}`;
|
||||
const localJobSn = `local-job-${createDredgingClientUuid()}`;
|
||||
const addedRecords = savedGroups.map<DredgingRecord>((group, index) => ({
|
||||
id: `${localJobSn}-${index + 1}`,
|
||||
dredgingJobSn: localJobSn,
|
||||
@@ -727,6 +755,7 @@ const Page = () => {
|
||||
value={viewType}
|
||||
onValueChange={(value) => {
|
||||
setViewType(value as DredgingViewType);
|
||||
setSort(undefined);
|
||||
setCurrentPage(1);
|
||||
setSelectedRecordId(null);
|
||||
}}
|
||||
|
||||
@@ -342,7 +342,12 @@ const Page = () => {
|
||||
<Button type="button" onClick={handleRegisterClick}>
|
||||
등록
|
||||
</Button>
|
||||
<Button type="button" color="red" onClick={isDeleteFile}>
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
disabled={isLoading || !sbmrgPrevntBizSn}
|
||||
onClick={isDeleteFile}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -317,7 +317,12 @@ const Page = () => {
|
||||
<Button type="button" onClick={handleRegister}>
|
||||
등록
|
||||
</Button>
|
||||
<Button type="button" color="red" onClick={handleDelete}>
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
disabled={loading || !businessId}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -12,7 +12,47 @@
|
||||
* 🔗 사용 API
|
||||
* - 하위코드페이지 캐시 목록 조회: GET /api/v1/code/cache/{upCd} (cms-service)
|
||||
*/
|
||||
import { getData } from "../api";
|
||||
import { getCmsData, getData } from "../api";
|
||||
|
||||
/** CMS 공통코드 캐시 API가 반환하는 하위코드의 공통 계약이다. */
|
||||
export type CommonCodeItem = {
|
||||
lwpoCd: string;
|
||||
upCd: string;
|
||||
lwpoCdNm: string;
|
||||
unit: string;
|
||||
prntLwpoCd: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 기존 CMS 캐시 조회 URL을 유지하면서 상위코드에 속한 하위코드를 조회한다.
|
||||
* 화면이 잘못된 응답을 정상 코드 목록으로 사용하지 않도록 배열 계약을 확인한다.
|
||||
*/
|
||||
export const getCommonCodes = async (
|
||||
upCd: string,
|
||||
): Promise<CommonCodeItem[]> => {
|
||||
const normalizedUpCd = upCd.trim();
|
||||
if (!normalizedUpCd) return [];
|
||||
|
||||
const data = await getCmsData(
|
||||
`/api/v1/code/cache/${encodeURIComponent(normalizedUpCd)}`,
|
||||
);
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(`공통코드 응답 형식이 올바르지 않습니다: ${normalizedUpCd}`);
|
||||
}
|
||||
|
||||
return data
|
||||
.map((item: any) => ({
|
||||
lwpoCd: String(item?.lwpoCd ?? "").trim(),
|
||||
upCd: String(item?.upCd ?? normalizedUpCd).trim(),
|
||||
lwpoCdNm: String(item?.lwpoCdNm ?? "").trim(),
|
||||
unit: String(item?.unit ?? "").trim(),
|
||||
prntLwpoCd:
|
||||
item?.prntLwpoCd === null || item?.prntLwpoCd === undefined
|
||||
? null
|
||||
: String(item.prntLwpoCd).trim(),
|
||||
}))
|
||||
.filter((item) => item.lwpoCd.length > 0);
|
||||
};
|
||||
|
||||
export const searchData = async<T extends Record<string, any>>(url: string, paramObj: T) => {
|
||||
const params = new URLSearchParams(
|
||||
@@ -29,10 +69,10 @@ export const searchData = async<T extends Record<string, any>>(url: string, para
|
||||
|
||||
// 상위코드로 하위코드 조회
|
||||
export const searchCmmnCd = async (upCd: string) => {
|
||||
const data = await getData(`/cms-service/api/v1/code/cache/${upCd}`);
|
||||
const data = await getCommonCodes(upCd);
|
||||
const filteredData = data.map((item: any) => ({
|
||||
lwpoCd: item.lwpoCd,
|
||||
lwpoCdNm: item.lwpoCdNm,
|
||||
}));
|
||||
return filteredData;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -165,7 +165,9 @@ export function VerticalTable({
|
||||
<>
|
||||
{headName ? (
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="mx-3 text-2xl font-medium text-label">{headName}</h3>
|
||||
<h3 className="mx-3 min-w-0 flex-1 text-2xl font-medium text-label">
|
||||
{headName}
|
||||
</h3>
|
||||
{showSaveButton && onSaveClick && (
|
||||
<Button type="button" color="primary" onClick={onSaveClick}>
|
||||
저장
|
||||
|
||||
@@ -111,6 +111,22 @@ const loadSelectOptions = async (url: string) => {
|
||||
.filter((item): item is SelectDataType => Boolean(item));
|
||||
};
|
||||
|
||||
/** ONSEMIRO 환경청 응답을 코드/환경청명 기준으로 SelectBox 옵션으로 변환한다. */
|
||||
const loadGovAreaOptions = async () => {
|
||||
const response = await getRawJson(
|
||||
"/sewerage-service/api/v1/onsemiro/addr/govArea",
|
||||
);
|
||||
return toResponseList(response)
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const { lclgvrnmntCd, lclgvrnmntNm } = item as Record<string, unknown>;
|
||||
const code = String(lclgvrnmntCd ?? "").trim();
|
||||
const name = String(lclgvrnmntNm ?? "").trim();
|
||||
return code && name ? { text: name, val: code } : null;
|
||||
})
|
||||
.filter((item): item is SelectDataType => Boolean(item));
|
||||
};
|
||||
|
||||
/** ONSEMIRO 환경청·시도·시군구 선택목록을 공유하는 상태 저장소다. */
|
||||
const useOnsemiroAddrStore = create<OnsemiroAddrStoreState>((set) => ({
|
||||
govArea: [],
|
||||
@@ -118,9 +134,7 @@ const useOnsemiroAddrStore = create<OnsemiroAddrStoreState>((set) => ({
|
||||
sgg: [],
|
||||
/** 환경청 선택목록을 조회해 한글명 순으로 저장한다. */
|
||||
fetchGovArea: async () => {
|
||||
const govArea = await loadSelectOptions(
|
||||
"/sewerage-service/api/v1/onsemiro/addr/govArea",
|
||||
);
|
||||
const govArea = await loadGovAreaOptions();
|
||||
set({
|
||||
govArea: govArea.sort((left, right) =>
|
||||
left.text.localeCompare(right.text, "ko"),
|
||||
|
||||
@@ -15,6 +15,11 @@ import { create } from "zustand";
|
||||
export const SEARCH_MODAL_DEFAULT_BASIC_TYPE = "tb_ms_swage_ppl";
|
||||
export const SEARCH_MODAL_DEFAULT_WORK_TYPE = "['200', '201', '204']";
|
||||
|
||||
export type AppliedBasicQuery = {
|
||||
basicType: string;
|
||||
query: string;
|
||||
};
|
||||
|
||||
// 날짜 조합 함수
|
||||
const getDateString = (
|
||||
year: string,
|
||||
@@ -49,6 +54,8 @@ export interface SearchModalState {
|
||||
detailTableData: any[];
|
||||
tableCount: number;
|
||||
detailTableCount: number;
|
||||
// 기본정보 화면을 마지막으로 만든 조회조건. 조회 훅과 다운로드 훅이 같은 값을 공유한다.
|
||||
appliedBasicQuery: AppliedBasicQuery | null;
|
||||
// 페이지네이션
|
||||
curPage: number;
|
||||
detailCurPage: number;
|
||||
@@ -93,6 +100,7 @@ export interface SearchModalState {
|
||||
setDetailTableData: (data: any[]) => void;
|
||||
setTableCount: (count: number) => void;
|
||||
setDetailTableCount: (count: number) => void;
|
||||
setAppliedBasicQuery: (query: AppliedBasicQuery | null) => void;
|
||||
setCurPage: (page: number) => void;
|
||||
setDetailCurPage: (page: number) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
@@ -133,6 +141,7 @@ const useSearchModalStore = create<SearchModalState>((set) => ({
|
||||
detailTableData: [],
|
||||
tableCount: 0,
|
||||
detailTableCount: 0,
|
||||
appliedBasicQuery: null,
|
||||
curPage: 1,
|
||||
detailCurPage: 1,
|
||||
isLoading: false,
|
||||
@@ -166,6 +175,7 @@ const useSearchModalStore = create<SearchModalState>((set) => ({
|
||||
setDetailTableData: (data) => set({ detailTableData: data }),
|
||||
setTableCount: (count) => set({ tableCount: count }),
|
||||
setDetailTableCount: (count) => set({ detailTableCount: count }),
|
||||
setAppliedBasicQuery: (appliedBasicQuery) => set({ appliedBasicQuery }),
|
||||
setCurPage: (page) => set({ curPage: page }),
|
||||
setDetailCurPage: (page) => set({ detailCurPage: page }),
|
||||
setIsLoading: (loading) => set({ isLoading: loading }),
|
||||
@@ -207,6 +217,7 @@ const useSearchModalStore = create<SearchModalState>((set) => ({
|
||||
detailTableData: [],
|
||||
tableCount: 0,
|
||||
detailTableCount: 0,
|
||||
appliedBasicQuery: null,
|
||||
curPage: 1,
|
||||
detailCurPage: 1,
|
||||
isResetPage: true,
|
||||
|
||||
+277
-238
@@ -6,60 +6,60 @@
|
||||
|
||||
## 2. 수행한 검증
|
||||
|
||||
| 날짜 | 검증 | 결과 |
|
||||
| --- | --- | --- |
|
||||
| 2026-07-22 | `git diff --check` | 통과, 공백 오류 없음 |
|
||||
| 2026-07-22 | `tsc --noEmit` 기본 실행 | 저장소 기존 `ignoreDeprecations=6.0` 값 오류 확인 |
|
||||
| 2026-07-22 | `tsc --noEmit --ignoreDeprecations 5.0` | 신규 `trtdst` 파일 오류 0건, 저장소 선행 오류 존재 |
|
||||
| 2026-07-22 | Next 프로덕션 빌드 | 실행 권한 승인 거절로 미수행 |
|
||||
| 2026-07-22 | 브라우저/API 통합 | 백엔드·메뉴 원천 미구현으로 미수행 |
|
||||
| 2026-07-22 | FE-0 import 경계 정적 확인 | 라우트가 client UI 파일을 직접 import하고 대상 파일 존재·`git diff --check` 통과 |
|
||||
| 2026-07-22 | FE-0 Next 개발 서버 재컴파일 | 실행 권한이 허용되지 않아 미수행, 사용자 재진입 확인 대기 |
|
||||
| 2026-07-22 | FE-0 메뉴 진입 | 사용자 화면 확인으로 Next Hook 경계 오류 해소와 화면 렌더링 통과 |
|
||||
| 2026-07-22 | FE-0 UI 마무리 정적 확인 | 공통 정렬 props·정렬 표시 목록 기반 선택·12열 필터 폭·버튼 공통 클래스 확인, UTF-8·`git diff --check` 통과 |
|
||||
| 2026-07-22 | FE-0 UI 마무리 브라우저 확인 | 3002 개발 서버 미실행으로 사용자 재확인 대기 |
|
||||
| 2026-07-22 | FE-0 UI 1차 사용자 재확인 | 시도·시군구 간격 미적용, 조회 버튼이 저장 버튼보다 약 `0.6rem` 왼쪽임을 캡처로 확인 |
|
||||
| 2026-07-22 | FE-0 UI 2차 정적 확인 | `.next` 생성 CSS에 `gap-10`·`col-span-12`가 없음을 확인하고 인라인 `2rem` 간격·고정 폭·조회 버튼 `0.6rem` 이동 적용, UTF-8·공백 검사 통과 |
|
||||
| 2026-07-22 | FE-0 최종 사용자 확인 | 처리분구관리 화면과 2차 UI 보정이 정상임을 사용자가 확인, 화면 단계 완료 판정 |
|
||||
| 2026-07-22 | FE-1 import·Hook·API 경로 정적 검사 | 상대 import 누락과 client 경계 누락 없음, 승인된 SFR-010-004 API 경로 확인 |
|
||||
| 2026-07-22 | FE-1 공백·한글 검사 | 신규/수정 대상 후행 공백 없음, 한글 본문 UTF-8 재조회 정상 |
|
||||
| 2026-07-22 | FE-1 `git diff --check` | 추적 파일 통과, 신규 파일은 별도 후행 공백 검사 통과 |
|
||||
| 2026-07-22 | FE-1 TypeScript 실행 | pnpm 비대화형 의존성 정리 중단 및 compiler 파일 샌드박스 `EPERM`으로 결과 미확보 |
|
||||
| 2026-07-22 | FE-1 Tailwind 경로 확인 | 기존 content에 `src/features`가 없음을 확인하고 `./src/**/*` 추가 |
|
||||
| 2026-07-22 | FE-1 브라우저·실 API | 3002 서버 미실행·백엔드 미구현으로 사용자 화면 확인 및 통합 단계 대기 |
|
||||
| 2026-07-22 | FE-1 사용자 1차 화면 확인 | 연도·지역 선택 컴포넌트, 사업명 폭, 상단 영역 분리, 불필요 PPL 버튼, 목록 열 순서 보완 요청 확인 |
|
||||
| 2026-07-22 | FE-1 사용자 1차 보정 정적 확인 | 기존 연도 옵션·I/I 지역 선택 재사용, 상단 단일 카드, PPL 버튼 제거, 9개 열 순서·단위 반영 |
|
||||
| 2026-07-22 | FE-1 WindowPopup 전환 정적 확인 | popup key·registry·content·부모 메시지, 처리분구 가나다순 체크 선택, 결과 파일 5종 확인 |
|
||||
| 2026-07-22 | FE-1 샘플 경로 Next 개발 서버 확인 | `/oper/exmn/register?sample=Y`, 사업등록 popup, 결과등록 popup 모두 HTTP 200 |
|
||||
| 2026-07-22 | FE-1 사업등록 2차 보정 | popup `920×620`, 5개 입력, 버튼 구성·green 스타일, client mount hydration 방어 정적 확인 |
|
||||
| 2026-07-22 | FE-1 샘플 전환·라우트 재확인 | 일반 목록, 샘플 목록, 사업등록 샘플 popup, 결과등록 샘플 popup 모두 HTTP 200 |
|
||||
| 2026-07-22 | FE-1 사업등록 3차 배치 보정 | 정밀조사연장 첫 줄 이동, 처리분구 높이 `20rem` 확장, 샘플 배지 제거 및 popup HTTP 200 확인 |
|
||||
| 2026-07-22 | FE-1 사업등록 4차 배치 보정 | 정밀조사연장 한 줄·입력 `7rem`, 처리분구 높이 `21rem`, 샘플 21개 및 초과 스크롤 구조 확인 |
|
||||
| 2026-07-22 | FE-1 팝업 5차 보정 | 연장 스피너 제거·최대 10자, 결과 popup `980×620`, 4개 사업요약·경계선 table, 샘플 배지 제거 및 두 popup HTTP 200 |
|
||||
| 2026-07-22 | FE-1 결과 popup 6차 보정 | `980×460`, 닫기 제거, 강한 격자 제거, 창 스크롤 차단·본문 조건부 스크롤 및 HTTP 200 확인 |
|
||||
| 2026-07-22 | FE-1 결과 popup 7차 보정 | html/body 문서 스크롤 잠금·복원, 업로드 행 높이 확대 및 HTTP 200 정적 확인 |
|
||||
| 2026-07-22 | FE-1 결과 popup 최종 크기 보정 | 두 캡처 외곽 높이 차이를 반영해 definition `980×340`으로 조정 |
|
||||
| 2026-07-22 | FE-1 결과 popup 최종 UI 보정 | 헤더·행 `gap-3` 통일, 선택 파일명 파란색·굵게 표시 및 HTTP 200 확인 |
|
||||
| 2026-07-22 | FE-1 결과 사업요약 순서 보정 | 사업차수→사업년도→행정구역→사업명, 비율 열·`gap-8` 적용 |
|
||||
| 2026-07-22 | FE-1 사업·결과 WindowPopup 사용자 확인 | 사업등록과 조사결과 업로드 최종 UI 완료 판정 |
|
||||
| 2026-07-23 | 조사현황 목록·상세 스크롤 보정 정적 확인 | 가로 여백 `1.2rem`, 전체 셀 행 선택, 양쪽 내부 스크롤 및 목록 페이지네이션 제거 확인 |
|
||||
| 2026-07-23 | 변경 파일 공백·UTF-8 검사 | `git diff --check` 통과, 대상 코드·문서 UTF-8 확인 |
|
||||
| 2026-07-23 | 조사현황 브라우저 재검증 | 3002 개발 서버 미실행으로 미수행 |
|
||||
| 2026-07-23 | 조사현황 사용자 재확인 | 목록·상세 실제 가로 경계 불일치, 상세 하단 여백, 일부 열만 클릭되는 문제 확인 |
|
||||
| 2026-07-23 | 조사현황 2차 구조 보정 | 동일 외부 스크롤 래퍼·stable gutter, 상세 남은 높이 사용, 전체 열 기존 `cellClick` 적용 |
|
||||
| 2026-07-23 | 조사현황 사용자 3차 재확인 | 목록 3행에서도 스크롤·하단선 잘림, 행·탭의 시각 영역과 실제 클릭 영역 불일치 확인 |
|
||||
| 2026-07-23 | 조사현황 3차 구조 보정 | 목록 `18rem`, 공통 표 `<tr>` 단위 `rowClick`, 80×32px 실제 button 탭, 상세 샘플 10행 적용 |
|
||||
| 2026-07-23 | 조사현황 사용자 4차 재확인 | 정밀조사 연장 우측 일부부터 보고서 영역만 선택되고 앞쪽 셀은 클릭되지 않아 공통 `rowClick` 방식 실패 판정 |
|
||||
| 2026-07-23 | 조사현황 4차 클릭 구조 교체 | 목록을 공통 Table 스타일의 화면 전용 표로 교체하고 모든 데이터 셀을 전체 면적 button으로 구성, 공통 `rowClick` 제거 |
|
||||
| 2026-07-23 | 조사관리 상세현황 사용자 최종 확인 | 전체 행 선택, 관로·맨홀 탭, 목록·상세 배치와 상세 집계 표시 확인 완료. 조사관리 상세현황 화면 단계 완료 판정 |
|
||||
| 2026-07-23 | git status·문서 정합성 점검 | 추적 코드 8개·신규 코드 15개와 FE 문서 4개를 대조하고 작업카드·구현·검증 문서의 단계 상태와 실제 파일 목록을 동기화 |
|
||||
| 2026-07-23 | 사업보고서 다운로드 정적 확인 | 첨부 ID가 있는 보고서 셀의 파란 밑줄 링크, 행 클릭 전파 차단, 기존 첨부서비스 Blob 다운로드, 샘플 미연결 안내 확인 |
|
||||
| 2026-07-27 | FE-0 신규 행 인라인 입력 정적 확인 | 변경상태 표시 열 제거, 선택 행정구역 신규 행 생성, 처리분구명 셀 입력, 내부 CREATE 상태 유지 확인 |
|
||||
| 2026-07-27 | FE-0 기존 행 인라인 수정 정적 확인 | 선택 행 수정 버튼, 처리분구명 셀 입력 전환, 원본 대비 `modified/clean` 상태 복원, 수정 모달 제거 확인 |
|
||||
| 2026-07-27 | FE-0 최초 자동 조회 정적 확인 | 최초 Effect에서 조건 없는 활성 목록 조회, 개발 모드 Effect 재실행 중복 요청 방지 ref 확인 |
|
||||
| 2026-07-27 | FE-0 저장 후 목록 유지 정적 확인 | 임시 선택값과 적용 조회조건 분리, 성공 즉시 저장 상태 정규화·삭제 행 제거, 적용 조건 서버 재조회 확인 |
|
||||
| 2026-07-27 | FE-0 실 API CRUD 사용자 확인 | 최초·조건 조회, 그리드 추가·수정, 무참조 삭제와 저장 후 최신 목록 표시까지 정상 동작 확인 |
|
||||
| 2026-07-27 | FE-0 TypeScript 재실행 | pnpm 비대화형 모듈 정리 중단 및 로컬 compiler 파일 `EPERM`으로 결과 미확보 |
|
||||
| 날짜 | 검증 | 결과 |
|
||||
| ---------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-07-22 | `git diff --check` | 통과, 공백 오류 없음 |
|
||||
| 2026-07-22 | `tsc --noEmit` 기본 실행 | 저장소 기존 `ignoreDeprecations=6.0` 값 오류 확인 |
|
||||
| 2026-07-22 | `tsc --noEmit --ignoreDeprecations 5.0` | 신규 `trtdst` 파일 오류 0건, 저장소 선행 오류 존재 |
|
||||
| 2026-07-22 | Next 프로덕션 빌드 | 실행 권한 승인 거절로 미수행 |
|
||||
| 2026-07-22 | 브라우저/API 통합 | 백엔드·메뉴 원천 미구현으로 미수행 |
|
||||
| 2026-07-22 | FE-0 import 경계 정적 확인 | 라우트가 client UI 파일을 직접 import하고 대상 파일 존재·`git diff --check` 통과 |
|
||||
| 2026-07-22 | FE-0 Next 개발 서버 재컴파일 | 실행 권한이 허용되지 않아 미수행, 사용자 재진입 확인 대기 |
|
||||
| 2026-07-22 | FE-0 메뉴 진입 | 사용자 화면 확인으로 Next Hook 경계 오류 해소와 화면 렌더링 통과 |
|
||||
| 2026-07-22 | FE-0 UI 마무리 정적 확인 | 공통 정렬 props·정렬 표시 목록 기반 선택·12열 필터 폭·버튼 공통 클래스 확인, UTF-8·`git diff --check` 통과 |
|
||||
| 2026-07-22 | FE-0 UI 마무리 브라우저 확인 | 3002 개발 서버 미실행으로 사용자 재확인 대기 |
|
||||
| 2026-07-22 | FE-0 UI 1차 사용자 재확인 | 시도·시군구 간격 미적용, 조회 버튼이 저장 버튼보다 약 `0.6rem` 왼쪽임을 캡처로 확인 |
|
||||
| 2026-07-22 | FE-0 UI 2차 정적 확인 | `.next` 생성 CSS에 `gap-10`·`col-span-12`가 없음을 확인하고 인라인 `2rem` 간격·고정 폭·조회 버튼 `0.6rem` 이동 적용, UTF-8·공백 검사 통과 |
|
||||
| 2026-07-22 | FE-0 최종 사용자 확인 | 처리분구관리 화면과 2차 UI 보정이 정상임을 사용자가 확인, 화면 단계 완료 판정 |
|
||||
| 2026-07-22 | FE-1 import·Hook·API 경로 정적 검사 | 상대 import 누락과 client 경계 누락 없음, 승인된 SFR-010-004 API 경로 확인 |
|
||||
| 2026-07-22 | FE-1 공백·한글 검사 | 신규/수정 대상 후행 공백 없음, 한글 본문 UTF-8 재조회 정상 |
|
||||
| 2026-07-22 | FE-1 `git diff --check` | 추적 파일 통과, 신규 파일은 별도 후행 공백 검사 통과 |
|
||||
| 2026-07-22 | FE-1 TypeScript 실행 | pnpm 비대화형 의존성 정리 중단 및 compiler 파일 샌드박스 `EPERM`으로 결과 미확보 |
|
||||
| 2026-07-22 | FE-1 Tailwind 경로 확인 | 기존 content에 `src/features`가 없음을 확인하고 `./src/**/*` 추가 |
|
||||
| 2026-07-22 | FE-1 브라우저·실 API | 3002 서버 미실행·백엔드 미구현으로 사용자 화면 확인 및 통합 단계 대기 |
|
||||
| 2026-07-22 | FE-1 사용자 1차 화면 확인 | 연도·지역 선택 컴포넌트, 사업명 폭, 상단 영역 분리, 불필요 PPL 버튼, 목록 열 순서 보완 요청 확인 |
|
||||
| 2026-07-22 | FE-1 사용자 1차 보정 정적 확인 | 기존 연도 옵션·I/I 지역 선택 재사용, 상단 단일 카드, PPL 버튼 제거, 9개 열 순서·단위 반영 |
|
||||
| 2026-07-22 | FE-1 WindowPopup 전환 정적 확인 | popup key·registry·content·부모 메시지, 처리분구 가나다순 체크 선택, 결과 파일 5종 확인 |
|
||||
| 2026-07-22 | FE-1 샘플 경로 Next 개발 서버 확인 | `/oper/exmn/register?sample=Y`, 사업등록 popup, 결과등록 popup 모두 HTTP 200 |
|
||||
| 2026-07-22 | FE-1 사업등록 2차 보정 | popup `920×620`, 5개 입력, 버튼 구성·green 스타일, client mount hydration 방어 정적 확인 |
|
||||
| 2026-07-22 | FE-1 샘플 전환·라우트 재확인 | 일반 목록, 샘플 목록, 사업등록 샘플 popup, 결과등록 샘플 popup 모두 HTTP 200 |
|
||||
| 2026-07-22 | FE-1 사업등록 3차 배치 보정 | 정밀조사연장 첫 줄 이동, 처리분구 높이 `20rem` 확장, 샘플 배지 제거 및 popup HTTP 200 확인 |
|
||||
| 2026-07-22 | FE-1 사업등록 4차 배치 보정 | 정밀조사연장 한 줄·입력 `7rem`, 처리분구 높이 `21rem`, 샘플 21개 및 초과 스크롤 구조 확인 |
|
||||
| 2026-07-22 | FE-1 팝업 5차 보정 | 연장 스피너 제거·최대 10자, 결과 popup `980×620`, 4개 사업요약·경계선 table, 샘플 배지 제거 및 두 popup HTTP 200 |
|
||||
| 2026-07-22 | FE-1 결과 popup 6차 보정 | `980×460`, 닫기 제거, 강한 격자 제거, 창 스크롤 차단·본문 조건부 스크롤 및 HTTP 200 확인 |
|
||||
| 2026-07-22 | FE-1 결과 popup 7차 보정 | html/body 문서 스크롤 잠금·복원, 업로드 행 높이 확대 및 HTTP 200 정적 확인 |
|
||||
| 2026-07-22 | FE-1 결과 popup 최종 크기 보정 | 두 캡처 외곽 높이 차이를 반영해 definition `980×340`으로 조정 |
|
||||
| 2026-07-22 | FE-1 결과 popup 최종 UI 보정 | 헤더·행 `gap-3` 통일, 선택 파일명 파란색·굵게 표시 및 HTTP 200 확인 |
|
||||
| 2026-07-22 | FE-1 결과 사업요약 순서 보정 | 사업차수→사업년도→행정구역→사업명, 비율 열·`gap-8` 적용 |
|
||||
| 2026-07-22 | FE-1 사업·결과 WindowPopup 사용자 확인 | 사업등록과 조사결과 업로드 최종 UI 완료 판정 |
|
||||
| 2026-07-23 | 조사현황 목록·상세 스크롤 보정 정적 확인 | 가로 여백 `1.2rem`, 전체 셀 행 선택, 양쪽 내부 스크롤 및 목록 페이지네이션 제거 확인 |
|
||||
| 2026-07-23 | 변경 파일 공백·UTF-8 검사 | `git diff --check` 통과, 대상 코드·문서 UTF-8 확인 |
|
||||
| 2026-07-23 | 조사현황 브라우저 재검증 | 3002 개발 서버 미실행으로 미수행 |
|
||||
| 2026-07-23 | 조사현황 사용자 재확인 | 목록·상세 실제 가로 경계 불일치, 상세 하단 여백, 일부 열만 클릭되는 문제 확인 |
|
||||
| 2026-07-23 | 조사현황 2차 구조 보정 | 동일 외부 스크롤 래퍼·stable gutter, 상세 남은 높이 사용, 전체 열 기존 `cellClick` 적용 |
|
||||
| 2026-07-23 | 조사현황 사용자 3차 재확인 | 목록 3행에서도 스크롤·하단선 잘림, 행·탭의 시각 영역과 실제 클릭 영역 불일치 확인 |
|
||||
| 2026-07-23 | 조사현황 3차 구조 보정 | 목록 `18rem`, 공통 표 `<tr>` 단위 `rowClick`, 80×32px 실제 button 탭, 상세 샘플 10행 적용 |
|
||||
| 2026-07-23 | 조사현황 사용자 4차 재확인 | 정밀조사 연장 우측 일부부터 보고서 영역만 선택되고 앞쪽 셀은 클릭되지 않아 공통 `rowClick` 방식 실패 판정 |
|
||||
| 2026-07-23 | 조사현황 4차 클릭 구조 교체 | 목록을 공통 Table 스타일의 화면 전용 표로 교체하고 모든 데이터 셀을 전체 면적 button으로 구성, 공통 `rowClick` 제거 |
|
||||
| 2026-07-23 | 조사관리 상세현황 사용자 최종 확인 | 전체 행 선택, 관로·맨홀 탭, 목록·상세 배치와 상세 집계 표시 확인 완료. 조사관리 상세현황 화면 단계 완료 판정 |
|
||||
| 2026-07-23 | git status·문서 정합성 점검 | 추적 코드 8개·신규 코드 15개와 FE 문서 4개를 대조하고 작업카드·구현·검증 문서의 단계 상태와 실제 파일 목록을 동기화 |
|
||||
| 2026-07-23 | 사업보고서 다운로드 정적 확인 | 첨부 ID가 있는 보고서 셀의 파란 밑줄 링크, 행 클릭 전파 차단, 기존 첨부서비스 Blob 다운로드, 샘플 미연결 안내 확인 |
|
||||
| 2026-07-27 | FE-0 신규 행 인라인 입력 정적 확인 | 변경상태 표시 열 제거, 선택 행정구역 신규 행 생성, 처리분구명 셀 입력, 내부 CREATE 상태 유지 확인 |
|
||||
| 2026-07-27 | FE-0 기존 행 인라인 수정 정적 확인 | 선택 행 수정 버튼, 처리분구명 셀 입력 전환, 원본 대비 `modified/clean` 상태 복원, 수정 모달 제거 확인 |
|
||||
| 2026-07-27 | FE-0 최초 자동 조회 정적 확인 | 최초 Effect에서 조건 없는 활성 목록 조회, 개발 모드 Effect 재실행 중복 요청 방지 ref 확인 |
|
||||
| 2026-07-27 | FE-0 저장 후 목록 유지 정적 확인 | 임시 선택값과 적용 조회조건 분리, 성공 즉시 저장 상태 정규화·삭제 행 제거, 적용 조건 서버 재조회 확인 |
|
||||
| 2026-07-27 | FE-0 실 API CRUD 사용자 확인 | 최초·조건 조회, 그리드 추가·수정, 무참조 삭제와 저장 후 최신 목록 표시까지 정상 동작 확인 |
|
||||
| 2026-07-27 | FE-0 TypeScript 재실행 | pnpm 비대화형 모듈 정리 중단 및 로컬 compiler 파일 `EPERM`으로 결과 미확보 |
|
||||
|
||||
## 3. FE-0 검증 결과
|
||||
|
||||
@@ -246,19 +246,19 @@
|
||||
|
||||
## 17. 2026-07-28 조사사업 실제 API·양식 다운로드 검증 결과
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 페이지 최초 조사사업 조회 | 통과 | 사용자 로그인 화면 확인 |
|
||||
| 조사사업 등록 후 목록 갱신 | 통과 | 저장 결과 즉시 표시 |
|
||||
| 등록 후 상세현황 자동 열림 방지 | 통과 | 목록만 갱신 |
|
||||
| 기존 사업 수정 팝업 정보 복원 | 통과 | 기본정보·처리분구 복원 |
|
||||
| 정밀조사연장 숫자 입력 | 통과 | 입력·저장 확인 |
|
||||
| 처리분구별 조사 입력양식 다운로드 | 통과 | 다중 시트 생성 확인 |
|
||||
| 본단위등급표 동적 관로번호 | 통과 | `#REF!` 없이 표시 |
|
||||
| 첫 처리분구만 예시 데이터 유지 | 통과 | 이후 시트는 빈 입력 양식 |
|
||||
| 이후 관로조사 글꼴 검정색 | 통과 | 사용자 최종 확인 |
|
||||
| 결과 미등록 상세현황 | 통과 | 오류 배너 없이 조회 결과 없음 표시 |
|
||||
| 조사보고서 양식 최종 클릭 | 확인 필요 | API와 프론트 호출 구현 완료 |
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --------------------------------- | --------- | ---------------------------------- |
|
||||
| 페이지 최초 조사사업 조회 | 통과 | 사용자 로그인 화면 확인 |
|
||||
| 조사사업 등록 후 목록 갱신 | 통과 | 저장 결과 즉시 표시 |
|
||||
| 등록 후 상세현황 자동 열림 방지 | 통과 | 목록만 갱신 |
|
||||
| 기존 사업 수정 팝업 정보 복원 | 통과 | 기본정보·처리분구 복원 |
|
||||
| 정밀조사연장 숫자 입력 | 통과 | 입력·저장 확인 |
|
||||
| 처리분구별 조사 입력양식 다운로드 | 통과 | 다중 시트 생성 확인 |
|
||||
| 본단위등급표 동적 관로번호 | 통과 | `#REF!` 없이 표시 |
|
||||
| 첫 처리분구만 예시 데이터 유지 | 통과 | 이후 시트는 빈 입력 양식 |
|
||||
| 이후 관로조사 글꼴 검정색 | 통과 | 사용자 최종 확인 |
|
||||
| 결과 미등록 상세현황 | 통과 | 오류 배너 없이 조회 결과 없음 표시 |
|
||||
| 조사보고서 양식 최종 클릭 | 확인 필요 | API와 프론트 호출 구현 완료 |
|
||||
|
||||
### 남은 검증
|
||||
|
||||
@@ -267,13 +267,13 @@
|
||||
|
||||
## 18. 2026-07-29 결과등록 파일별 검토 내역 정적 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 파일별 검토 결과 독립 보관 | 통과 | Excel·관로 PDF·맨홀 PDF 상태와 메시지를 파일 구분별로 관리 |
|
||||
| Excel 재선택 시 의존 검토 초기화 | 통과 | 관로·맨홀 PDF 검토 상태와 내역을 함께 폐기 |
|
||||
| 검토 항목·건수·실패 사유 표시 | 통과 | 검토한 파일만 하단 내역에 표시 |
|
||||
| 최종 저장 검토 해시 | 통과 | 조사결과 Excel의 정상 검토 결과를 사용 |
|
||||
| 프론트 변경 공백·문법 흔적 검사 | 통과 | 대상 파일 `git diff --check` 통과 |
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| -------------------------------- | ---- | ---------------------------------------------------------- |
|
||||
| 파일별 검토 결과 독립 보관 | 통과 | Excel·관로 PDF·맨홀 PDF 상태와 메시지를 파일 구분별로 관리 |
|
||||
| Excel 재선택 시 의존 검토 초기화 | 통과 | 관로·맨홀 PDF 검토 상태와 내역을 함께 폐기 |
|
||||
| 검토 항목·건수·실패 사유 표시 | 통과 | 검토한 파일만 하단 내역에 표시 |
|
||||
| 최종 저장 검토 해시 | 통과 | 조사결과 Excel의 정상 검토 결과를 사용 |
|
||||
| 프론트 변경 공백·문법 흔적 검사 | 통과 | 대상 파일 `git diff --check` 통과 |
|
||||
|
||||
### 남은 검증
|
||||
|
||||
@@ -282,13 +282,13 @@
|
||||
|
||||
## 19. 2026-07-29 사업보고서 원본 파일명 표시 정적 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| -------------------------------- | ---- | ----------------------------------------------------------------------------------------------- |
|
||||
| 첨부 메타데이터 원본 파일명 조회 | 통과 | 원문 JSON의 직접 배열·`data`·`content` 응답을 정규화한 뒤 첨부 ID가 일치하는 `orgnlFileNm` 선택 |
|
||||
| 목록 보고서 셀 표시 | 통과 | 원본 파일명 우선, 메타데이터 실패 시 `있음`, 미첨부 시 `없음` |
|
||||
| 다운로드 파일명 | 통과 | 원본 파일명 우선 사용 |
|
||||
| 목록 조회 장애 격리 | 통과 | 첨부 메타 조회 실패를 행별 `null`로 처리 |
|
||||
| 변경 파일 공백 검사 | 통과 | `git diff --check` 통과 |
|
||||
| 목록 보고서 셀 표시 | 통과 | 원본 파일명 우선, 메타데이터 실패 시 `있음`, 미첨부 시 `없음` |
|
||||
| 다운로드 파일명 | 통과 | 원본 파일명 우선 사용 |
|
||||
| 목록 조회 장애 격리 | 통과 | 첨부 메타 조회 실패를 행별 `null`로 처리 |
|
||||
| 변경 파일 공백 검사 | 통과 | `git diff --check` 통과 |
|
||||
|
||||
### 남은 검증
|
||||
|
||||
@@ -296,17 +296,17 @@
|
||||
|
||||
## 20. 2026-07-29 결과등록 사용자 통합 검증과 목록 UI 최종 확인
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 조사결과 Excel 데이터 검토 | 통과 | 처리분구별 PPL·MNHL 및 본단위구간 검토 완료 |
|
||||
| 관로 PDF 관로번호 검토 | 통과 | PDF 관로번호가 Excel 관로번호에 포함되는지 확인 |
|
||||
| 파일별 검토 상태 표시 | 통과 | 성공은 연한 초록색·검토 완료, 실패는 연한 빨간색·검토 실패 |
|
||||
| 최종 저장 활성 조건 | 통과 | Excel과 관로 PDF 검토가 모두 정상일 때 활성 |
|
||||
| 조사결과 최종 저장 | 통과 | 사용자 화면에서 PPL/MNHL 저장·집계 완료 메시지 확인 |
|
||||
| 사업보고서 원본 파일명 표시 | 통과 | `있음` 대신 첨부 원본 파일명 표시 확인 |
|
||||
| 보고서 열 폭 | 통과 | 보고서 22%, 사업차수 6%, 처리구역·분구/정밀조사 연장 각 8% |
|
||||
| 좁은 헤더 한 줄 표시 | 통과 | 헤더 줄바꿈 금지와 간격·글자 크기 보정 |
|
||||
| 프론트 공백 검사 | 통과 | `git diff --check` |
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --------------------------- | ---- | ---------------------------------------------------------- |
|
||||
| 조사결과 Excel 데이터 검토 | 통과 | 처리분구별 PPL·MNHL 및 본단위구간 검토 완료 |
|
||||
| 관로 PDF 관로번호 검토 | 통과 | PDF 관로번호가 Excel 관로번호에 포함되는지 확인 |
|
||||
| 파일별 검토 상태 표시 | 통과 | 성공은 연한 초록색·검토 완료, 실패는 연한 빨간색·검토 실패 |
|
||||
| 최종 저장 활성 조건 | 통과 | Excel과 관로 PDF 검토가 모두 정상일 때 활성 |
|
||||
| 조사결과 최종 저장 | 통과 | 사용자 화면에서 PPL/MNHL 저장·집계 완료 메시지 확인 |
|
||||
| 사업보고서 원본 파일명 표시 | 통과 | `있음` 대신 첨부 원본 파일명 표시 확인 |
|
||||
| 보고서 열 폭 | 통과 | 보고서 22%, 사업차수 6%, 처리구역·분구/정밀조사 연장 각 8% |
|
||||
| 좁은 헤더 한 줄 표시 | 통과 | 헤더 줄바꿈 금지와 간격·글자 크기 보정 |
|
||||
| 프론트 공백 검사 | 통과 | `git diff --check` |
|
||||
|
||||
### 남은 검증
|
||||
|
||||
@@ -316,14 +316,14 @@
|
||||
|
||||
## 21. 2026-07-29 조사 상세현황 실제 집계 조회
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 프론트 API URL·파라미터 계약 | 정적 확인 | `/sections?jobType=PPL\|MNHL` |
|
||||
| 관로 집계 필드 매핑 | 정적 확인 | PPL 전체이상·구조·운영·기타, 연장·주행거리 |
|
||||
| 맨홀 집계 필드 매핑 | 정적 확인 | MNHL 전체이상·구조·운영·기타 |
|
||||
| 빈 배열 화면 | 정적 확인 | `조회된 결과가 없습니다.` |
|
||||
| 빠른 사업·탭 전환 보호 | 유지 | request id 기반 이전 응답 차단 |
|
||||
| 실제 서버 화면 검증 | 대기 | 백엔드 재기동 후 사용자 확인 필요 |
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| ---------------------------- | --------- | ------------------------------------------ |
|
||||
| 프론트 API URL·파라미터 계약 | 정적 확인 | `/sections?jobType=PPL\|MNHL` |
|
||||
| 관로 집계 필드 매핑 | 정적 확인 | PPL 전체이상·구조·운영·기타, 연장·주행거리 |
|
||||
| 맨홀 집계 필드 매핑 | 정적 확인 | MNHL 전체이상·구조·운영·기타 |
|
||||
| 빈 배열 화면 | 정적 확인 | `조회된 결과가 없습니다.` |
|
||||
| 빠른 사업·탭 전환 보호 | 유지 | request id 기반 이전 응답 차단 |
|
||||
| 실제 서버 화면 검증 | 대기 | 백엔드 재기동 후 사용자 확인 필요 |
|
||||
|
||||
### 남은 검증
|
||||
|
||||
@@ -332,14 +332,14 @@
|
||||
|
||||
## 22. 2026-07-29 조사 상세보기 전체 집계 API 정적 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 상세 API 요청 | 정적 확인 | 사업·처리분구·PPL/MNHL·관리번호 전달 |
|
||||
| 실제/샘플 데이터 분리 | 통과 | `sample=Y`는 기존 샘플, 일반 URL은 서버 응답 |
|
||||
| 기존 표 계약 유지 | 통과 | 고정 열·다단 결함 열·소계·합계·정렬 재사용 |
|
||||
| 요청 순서 보호 | 통과 | effect cleanup 후 이전 응답 무시 |
|
||||
| 후속 기능 격리 | 통과 | UNIT·결함별 화면은 아직 실 API 미호출 |
|
||||
| 변경 공백 검사 | 통과 | `git diff --check` |
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --------------------- | --------- | -------------------------------------------- |
|
||||
| 상세 API 요청 | 정적 확인 | 사업·처리분구·PPL/MNHL·관리번호 전달 |
|
||||
| 실제/샘플 데이터 분리 | 통과 | `sample=Y`는 기존 샘플, 일반 URL은 서버 응답 |
|
||||
| 기존 표 계약 유지 | 통과 | 고정 열·다단 결함 열·소계·합계·정렬 재사용 |
|
||||
| 요청 순서 보호 | 통과 | effect cleanup 후 이전 응답 무시 |
|
||||
| 후속 기능 격리 | 통과 | UNIT·결함별 화면은 아직 실 API 미호출 |
|
||||
| 변경 공백 검사 | 통과 | `git diff --check` |
|
||||
|
||||
### 잔여 확인
|
||||
|
||||
@@ -348,15 +348,15 @@
|
||||
|
||||
## 23. 2026-07-29 맨홀 전체 상세표 고정정보 정적 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 맨홀 식별 열 용어 | 통과 | `맨홀번호` 대신 `관로번호` 표시 |
|
||||
| 관로 고정 열 | 정적 확인 | 보고서번호부터 주행거리까지 표시 |
|
||||
| 맨홀 구조·운영 등급 | 정적 확인 | 공통 열 뒤에 시설 등급코드 두 열 표시 |
|
||||
| 소계·합계 | 정적 확인 | 관로는 연장·주행거리, 맨홀은 구조·운영 등급까지 합산 |
|
||||
| 조사유형별 결함 열 | 정적 확인 | 고정 열 이후 PPL/MNHL 코드 정의 분기 |
|
||||
| 엑셀 다운로드 열 | 정적 확인 | 관로·맨홀 화면별 고정 열과 결함 집계 생성 |
|
||||
| 변경 화면 ESLint | 통과 | `ExmnSurveyDetailPopupPage.tsx` 오류·경고 없음 |
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| ------------------- | --------- | ---------------------------------------------------- |
|
||||
| 맨홀 식별 열 용어 | 통과 | `맨홀번호` 대신 `관로번호` 표시 |
|
||||
| 관로 고정 열 | 정적 확인 | 보고서번호부터 주행거리까지 표시 |
|
||||
| 맨홀 구조·운영 등급 | 정적 확인 | 공통 열 뒤에 시설 등급코드 두 열 표시 |
|
||||
| 소계·합계 | 정적 확인 | 관로는 연장·주행거리, 맨홀은 구조·운영 등급까지 합산 |
|
||||
| 조사유형별 결함 열 | 정적 확인 | 고정 열 이후 PPL/MNHL 코드 정의 분기 |
|
||||
| 엑셀 다운로드 열 | 정적 확인 | 관로·맨홀 화면별 고정 열과 결함 집계 생성 |
|
||||
| 변경 화면 ESLint | 통과 | `ExmnSurveyDetailPopupPage.tsx` 오류·경고 없음 |
|
||||
|
||||
### 남은 검증
|
||||
|
||||
@@ -366,15 +366,15 @@
|
||||
|
||||
## 24. 2026-07-29 상세보기 처리분구 선택 정적 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 조사유형별 처리분구 목록 | 정적 확인 | 기존 `/sections` API를 PPL·MNHL로 구분 호출 |
|
||||
| 진입 처리분구 기본값 | 통과 | 조사 상세현황에서 선택한 처리분구 ID 유지 |
|
||||
| 처리분구 변경 재조회 | 정적 확인 | 선택 ID를 시설 상세 API에 전달 |
|
||||
| 이전 조회 상태 초기화 | 통과 | 상세 행·관리번호·정렬·안내 초기화 |
|
||||
| 제목·다운로드 이름 | 통과 | 현재 선택 처리분구명 사용 |
|
||||
| 대상 화면 ESLint | 통과 | `ExmnSurveyDetailPopupPage.tsx` 오류·경고 없음 |
|
||||
| 변경 공백 검사 | 통과 | `git diff --check` |
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| ------------------------ | --------- | ---------------------------------------------- |
|
||||
| 조사유형별 처리분구 목록 | 정적 확인 | 기존 `/sections` API를 PPL·MNHL로 구분 호출 |
|
||||
| 진입 처리분구 기본값 | 통과 | 조사 상세현황에서 선택한 처리분구 ID 유지 |
|
||||
| 처리분구 변경 재조회 | 정적 확인 | 선택 ID를 시설 상세 API에 전달 |
|
||||
| 이전 조회 상태 초기화 | 통과 | 상세 행·관리번호·정렬·안내 초기화 |
|
||||
| 제목·다운로드 이름 | 통과 | 현재 선택 처리분구명 사용 |
|
||||
| 대상 화면 ESLint | 통과 | `ExmnSurveyDetailPopupPage.tsx` 오류·경고 없음 |
|
||||
| 변경 공백 검사 | 통과 | `git diff --check` |
|
||||
|
||||
### 남은 검증
|
||||
|
||||
@@ -383,15 +383,15 @@
|
||||
|
||||
## 25. 2026-07-29 관로 본단위구간 실데이터 연결 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 실제 API 연결 | 통과 | PPL 단위구간 전용 API 호출 |
|
||||
| 초기 전체 조회 | 정적 확인 | 관리번호 없이 현재 처리분구 전체 |
|
||||
| 관리번호 부분 조회 | 정적 확인 | 조회 버튼 확정값 전달 |
|
||||
| 상태·결함 명칭 | 통과 | 코드 기반 프론트 매핑 |
|
||||
| 빈 상세 단위구간 | 통과 | 결함 필드 하이픈 표시 |
|
||||
| 기존 샘플·다운로드 | 유지 | 현재 14열 화면 계약 재사용 |
|
||||
| 대상 ESLint | 통과 | 관련 4개 파일 오류·경고 없음 |
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| ------------------ | --------- | -------------------------------- |
|
||||
| 실제 API 연결 | 통과 | PPL 단위구간 전용 API 호출 |
|
||||
| 초기 전체 조회 | 정적 확인 | 관리번호 없이 현재 처리분구 전체 |
|
||||
| 관리번호 부분 조회 | 정적 확인 | 조회 버튼 확정값 전달 |
|
||||
| 상태·결함 명칭 | 통과 | 코드 기반 프론트 매핑 |
|
||||
| 빈 상세 단위구간 | 통과 | 결함 필드 하이픈 표시 |
|
||||
| 기존 샘플·다운로드 | 유지 | 현재 14열 화면 계약 재사용 |
|
||||
| 대상 ESLint | 통과 | 관련 4개 파일 오류·경고 없음 |
|
||||
|
||||
### 남은 검증
|
||||
|
||||
@@ -411,14 +411,14 @@
|
||||
|
||||
## 26. 2026-07-29 구조·운영 결함별 실데이터 연결 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| -------------- | ---- | ---------------------------------------- |
|
||||
| 결함 링크 요청 | 통과 | 선택 `defectType`을 시설 상세 API에 전달 |
|
||||
| 간소화 팝업 | 통과 | 조회조건·모드 없이 결과와 다운로드 표시 |
|
||||
| 선택 코드 열 | 통과 | 구조 또는 운영 코드 정의만 사용 |
|
||||
| 선택 시설 행 | 통과 | 선택 분류 결함이 존재하는 행만 표시 |
|
||||
| 소계·합계 | 통과 | 현재 표시 행·열 기준 계산 |
|
||||
| 대상 ESLint | 통과 | API·팝업 변경 파일 오류 없음 |
|
||||
| 간소화 팝업 | 통과 | 조회조건·모드 없이 결과와 다운로드 표시 |
|
||||
| 선택 코드 열 | 통과 | 구조 또는 운영 코드 정의만 사용 |
|
||||
| 선택 시설 행 | 통과 | 선택 분류 결함이 존재하는 행만 표시 |
|
||||
| 소계·합계 | 통과 | 현재 표시 행·열 기준 계산 |
|
||||
| 대상 ESLint | 통과 | API·팝업 변경 파일 오류 없음 |
|
||||
|
||||
### 남은 검증
|
||||
|
||||
@@ -427,15 +427,15 @@
|
||||
|
||||
## 27. 2026-07-30 조사 상세현황 다중 시트 다운로드 정적 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 프론트 API 연결 | 구현 | `/sections/download?jobType=PPL\|MNHL` |
|
||||
| 다운로드 파일 | 구현 | 서버 Blob 1개 저장 |
|
||||
| 시트 계약 | 정적 확인 | 종합집계 1 + 처리분구 상세 N |
|
||||
| 본단위구간 제외 | 정적 확인 | 서버 생성 계약에서 제외 |
|
||||
| 빈 결과·진행 상태 | 구현 | 버튼 비활성·안내 표시 |
|
||||
| 샘플 모드 | 제거 | 실제 API만 사용하도록 단일화 |
|
||||
| ESLint | 미실행 | pnpm TTY 오류와 로컬 eslint 파일 접근 `EPERM` |
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| ----------------- | --------- | --------------------------------------------- |
|
||||
| 프론트 API 연결 | 구현 | `/sections/download?jobType=PPL\|MNHL` |
|
||||
| 다운로드 파일 | 구현 | 서버 Blob 1개 저장 |
|
||||
| 시트 계약 | 정적 확인 | 종합집계 1 + 처리분구 상세 N |
|
||||
| 본단위구간 제외 | 정적 확인 | 서버 생성 계약에서 제외 |
|
||||
| 빈 결과·진행 상태 | 구현 | 버튼 비활성·안내 표시 |
|
||||
| 샘플 모드 | 제거 | 실제 API만 사용하도록 단일화 |
|
||||
| ESLint | 미실행 | pnpm TTY 오류와 로컬 eslint 파일 접근 `EPERM` |
|
||||
|
||||
### 남은 검증
|
||||
|
||||
@@ -445,13 +445,13 @@
|
||||
|
||||
## 28. 2026-07-30 샘플 데이터 보기 제거 정적 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 샘플 데이터 파일 | 제거 | `exmn-sample-data.ts` 삭제 |
|
||||
| 샘플 URL·상태 분기 | 통과 | 기능 범위에서 `sample=`, `sampleMode` 참조 0건 |
|
||||
| 목록·팝업 실제 API | 통과 | 사업·처리분구·검토·집계·상세 API 호출 유지 |
|
||||
| 양식 생성 예시행 정리 | 영향 없음 | 백엔드 실제 양식 생성 로직 유지 |
|
||||
| 전체 빌드 | 미실행 | 사용자 요청에 따라 정적 확인만 수행 |
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --------------------- | --------- | ---------------------------------------------- |
|
||||
| 샘플 데이터 파일 | 제거 | `exmn-sample-data.ts` 삭제 |
|
||||
| 샘플 URL·상태 분기 | 통과 | 기능 범위에서 `sample=`, `sampleMode` 참조 0건 |
|
||||
| 목록·팝업 실제 API | 통과 | 사업·처리분구·검토·집계·상세 API 호출 유지 |
|
||||
| 양식 생성 예시행 정리 | 영향 없음 | 백엔드 실제 양식 생성 로직 유지 |
|
||||
| 전체 빌드 | 미실행 | 사용자 요청에 따라 정적 확인만 수행 |
|
||||
|
||||
### 남은 검증
|
||||
|
||||
@@ -461,17 +461,17 @@
|
||||
|
||||
## 29. 2026-07-30 샘플 제거 변경분 코드 리뷰 결과
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 목록 최초 조회 | 통과 | 빈 조회조건으로 실제 목록 API 호출 |
|
||||
| 등록·수정 후 동기화 | 통과 | popup 메시지 수신 후 현재 조건으로 목록 재조회 |
|
||||
| 결과등록 | 통과 | 실제 상세조회·검토·첨부 연결·commit 흐름 유지 |
|
||||
| 종합집계 | 통과 | 선택 사업·조사유형으로 실제 집계 API 호출 |
|
||||
| 전체·본단위·결함별 상세 | 통과 | 사업·처리분구·유형 파라미터와 실제 API 호출 유지 |
|
||||
| WindowPopup props | 통과 | 제거된 샘플 props 참조 없음 |
|
||||
| 런타임 샘플 참조 | 통과 | 기능 범위 검색 결과 0건 |
|
||||
| diff 형식 | 통과 | 프론트·공통 문서 `git diff --check` 오류 없음 |
|
||||
| TypeScript/전체 빌드 | 미실행 | 로컬 TypeScript 접근 `EPERM`, 사용자 요청에 따라 장시간 빌드 생략 |
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| ----------------------- | ------ | ----------------------------------------------------------------- |
|
||||
| 목록 최초 조회 | 통과 | 빈 조회조건으로 실제 목록 API 호출 |
|
||||
| 등록·수정 후 동기화 | 통과 | popup 메시지 수신 후 현재 조건으로 목록 재조회 |
|
||||
| 결과등록 | 통과 | 실제 상세조회·검토·첨부 연결·commit 흐름 유지 |
|
||||
| 종합집계 | 통과 | 선택 사업·조사유형으로 실제 집계 API 호출 |
|
||||
| 전체·본단위·결함별 상세 | 통과 | 사업·처리분구·유형 파라미터와 실제 API 호출 유지 |
|
||||
| WindowPopup props | 통과 | 제거된 샘플 props 참조 없음 |
|
||||
| 런타임 샘플 참조 | 통과 | 기능 범위 검색 결과 0건 |
|
||||
| diff 형식 | 통과 | 프론트·공통 문서 `git diff --check` 오류 없음 |
|
||||
| TypeScript/전체 빌드 | 미실행 | 로컬 TypeScript 접근 `EPERM`, 사용자 요청에 따라 장시간 빌드 생략 |
|
||||
|
||||
### 남은 수동 회귀
|
||||
|
||||
@@ -481,15 +481,15 @@
|
||||
|
||||
## 30. 2026-07-30 배수방식·관종 명칭 표시 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 상세 응답 타입 | 반영 | 코드 2개와 명칭 2개를 함께 유지 |
|
||||
| 화면 표시 우선순위 | 정적 확인 | 명칭 우선, 없으면 코드 |
|
||||
| 고정 열 정렬 | 정적 확인 | 화면 표시 명칭 기준 |
|
||||
| 클라이언트 상세 Excel | 정적 확인 | 배수방식·관종 명칭 기록 |
|
||||
| 기존 상세표 구조 | 영향 없음 | 고정 열·결함 열·소계·합계 유지 |
|
||||
| 변경 공백 검사 | 통과 | `git diff --check` |
|
||||
| 대상 ESLint | 환경 제한 | pnpm 비대화형 설치 중단 및 eslint 모듈 접근 `EPERM` |
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --------------------- | --------- | --------------------------------------------------- |
|
||||
| 상세 응답 타입 | 반영 | 코드 2개와 명칭 2개를 함께 유지 |
|
||||
| 화면 표시 우선순위 | 정적 확인 | 명칭 우선, 없으면 코드 |
|
||||
| 고정 열 정렬 | 정적 확인 | 화면 표시 명칭 기준 |
|
||||
| 클라이언트 상세 Excel | 정적 확인 | 배수방식·관종 명칭 기록 |
|
||||
| 기존 상세표 구조 | 영향 없음 | 고정 열·결함 열·소계·합계 유지 |
|
||||
| 변경 공백 검사 | 통과 | `git diff --check` |
|
||||
| 대상 ESLint | 환경 제한 | pnpm 비대화형 설치 중단 및 eslint 모듈 접근 `EPERM` |
|
||||
|
||||
### 남은 수동 확인
|
||||
|
||||
@@ -499,18 +499,18 @@
|
||||
|
||||
## 31. 2026-07-31 지도보기 이동·조사결과 레이어 정적 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
|---|---|---|
|
||||
| 지도 이동 query | 통과 | 사업·행정구역·조사유형·처리분구 전달 |
|
||||
| 초기 행정구역 연계 | 통과 | 기존 지도 검색 이벤트 재사용 |
|
||||
| 조사결과 트리 구성 | 통과 | 루트와 PPL·MNHL 가상 노드 구성 |
|
||||
| 설정 조건 | 통과 | 사업차수·구조·운영 등급 3개만 제공 |
|
||||
| 자동 조회 | 통과 | 설정 변경 시 레이어 재조회 |
|
||||
| 레이어 분리 | 통과 | PPL 선·MNHL 점 소스와 필터 상태 독립 |
|
||||
| 응답 순서 보호 | 통과 | 조사유형별 요청 순번 적용 |
|
||||
| 변경 공백 검사 | 통과 | `git diff --check` |
|
||||
| 대상 ESLint | 환경 제한 | pnpm 비대화형 설치 중단, 직접 eslint 모듈 접근 `EPERM` |
|
||||
| 브라우저 수동 검증 | 대기 | 서버 재기동 후 실제 지도에서 확인 필요 |
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| ------------------ | --------- | ------------------------------------------------------ |
|
||||
| 지도 이동 query | 통과 | 사업·행정구역·조사유형·처리분구 전달 |
|
||||
| 초기 행정구역 연계 | 통과 | 기존 지도 검색 이벤트 재사용 |
|
||||
| 조사결과 트리 구성 | 통과 | 루트와 PPL·MNHL 가상 노드 구성 |
|
||||
| 설정 조건 | 통과 | 사업차수·구조·운영 등급 3개만 제공 |
|
||||
| 자동 조회 | 통과 | 설정 변경 시 레이어 재조회 |
|
||||
| 레이어 분리 | 통과 | PPL 선·MNHL 점 소스와 필터 상태 독립 |
|
||||
| 응답 순서 보호 | 통과 | 조사유형별 요청 순번 적용 |
|
||||
| 변경 공백 검사 | 통과 | `git diff --check` |
|
||||
| 대상 ESLint | 환경 제한 | pnpm 비대화형 설치 중단, 직접 eslint 모듈 접근 `EPERM` |
|
||||
| 브라우저 수동 검증 | 대기 | 서버 재기동 후 실제 지도에서 확인 필요 |
|
||||
|
||||
### 남은 수동 확인
|
||||
|
||||
@@ -521,43 +521,43 @@
|
||||
|
||||
## 32. 2026-07-31 지도 우측 조사관리 탭 정적 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
|---|---|---|
|
||||
| 조사관리 탭 분기 | 통과 | `surveyOverlayType=EXMN_RESULT`를 일반 시설보다 우선 판별 |
|
||||
| 탭 식별자 | 통과 | 조사시설물 ID와 맨홀 위치로 구성 |
|
||||
| 조사 결과 주입 | 통과 | 관로·맨홀 입력정보 컴포넌트에 선택적 ReactNode 전달 |
|
||||
| 관로 부분 PDF 호출 | 통과 | 사업 ID와 관로번호를 URL 인코딩하여 Blob 조회 |
|
||||
| 맨홀 PDF 보류 | 통과 | 버튼과 별도 보류 안내 모두 미표시 |
|
||||
| 변경 공백 검사 | 통과 | 프론트·백엔드 `git diff --check` |
|
||||
| 브라우저 수동 검증 | 대기 | 개발 서버에서 실제 지도 선택과 팝업 허용 상태 확인 필요 |
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| ------------------ | ---- | --------------------------------------------------------- |
|
||||
| 조사관리 탭 분기 | 통과 | `surveyOverlayType=EXMN_RESULT`를 일반 시설보다 우선 판별 |
|
||||
| 탭 식별자 | 통과 | 조사시설물 ID와 맨홀 위치로 구성 |
|
||||
| 조사 결과 주입 | 통과 | 관로·맨홀 입력정보 컴포넌트에 선택적 ReactNode 전달 |
|
||||
| 관로 부분 PDF 호출 | 통과 | 사업 ID와 관로번호를 URL 인코딩하여 Blob 조회 |
|
||||
| 맨홀 PDF 보류 | 통과 | 버튼과 별도 보류 안내 모두 미표시 |
|
||||
| 변경 공백 검사 | 통과 | 프론트·백엔드 `git diff --check` |
|
||||
| 브라우저 수동 검증 | 대기 | 개발 서버에서 실제 지도 선택과 팝업 허용 상태 확인 필요 |
|
||||
|
||||
- 사용자 요청에 따라 전체 프론트 빌드와 장시간 Gradle 작업은 수행하지 않았다.
|
||||
|
||||
## 33. 2026-07-31 조사관리 탭 유지관리 영역·부분 PDF 보정
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
|---|---|---|
|
||||
| 조사관리 전용 표시 | 정적 통과 | 조사결과 아래 일반 유지관리 3개 영역 숨김 |
|
||||
| 일반 관로 탭 회귀 | 정적 통과 | 조사관리 탭이 아닐 때 기존 유지관리 영역 유지 |
|
||||
| 부분 관로 PDF | 정적 통과 | PDF에 포함된 활성 관로 쪽 범위만 저장 |
|
||||
| PDF의 미등록 관로 | 정적 통과 | 활성 조사시설에 없는 번호가 있으면 저장 거부 |
|
||||
| 선택 관로 PDF 누락 | 정적 통과 | 선택 관로 보고서 없음 메시지 반환 |
|
||||
| 브라우저·단위 테스트 | 대기 | 백엔드 재기동 후 실제 첨부 PDF로 확인 필요 |
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| -------------------- | --------- | --------------------------------------------- |
|
||||
| 조사관리 전용 표시 | 정적 통과 | 조사결과 아래 일반 유지관리 3개 영역 숨김 |
|
||||
| 일반 관로 탭 회귀 | 정적 통과 | 조사관리 탭이 아닐 때 기존 유지관리 영역 유지 |
|
||||
| 부분 관로 PDF | 정적 통과 | PDF에 포함된 활성 관로 쪽 범위만 저장 |
|
||||
| PDF의 미등록 관로 | 정적 통과 | 활성 조사시설에 없는 번호가 있으면 저장 거부 |
|
||||
| 선택 관로 PDF 누락 | 정적 통과 | 선택 관로 보고서 없음 메시지 반환 |
|
||||
| 브라우저·단위 테스트 | 대기 | 백엔드 재기동 후 실제 첨부 PDF로 확인 필요 |
|
||||
|
||||
## 34. 2026-07-31 최종 정적 회귀점검
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
|---|---|---|
|
||||
| 처리분구·사업·결과등록 | 구현 확인 | 실제 API 호출과 저장 후 재조회 흐름 유지 |
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| ---------------------------- | --------- | -------------------------------------------------- |
|
||||
| 처리분구·사업·결과등록 | 구현 확인 | 실제 API 호출과 저장 후 재조회 흐름 유지 |
|
||||
| 종합·전체·본단위·결함별 조회 | 구현 확인 | PPL/MNHL 및 `ALL/STRUCTURAL/OPERATIONAL` 계약 유지 |
|
||||
| 다중 시트 다운로드 | 구현 확인 | 종합 1 + 처리분구 상세 N, 본단위구간 제외 |
|
||||
| 지도 이동·행정구역 | 구현 확인 | 시도·시군구 query와 공통 검색 이벤트 연결 |
|
||||
| 지도 조사 레이어 | 구현 확인 | PPL 선·MNHL 점, 사업차수·구조·운영 등급 필터 |
|
||||
| 조사유형 시각 구분 | 구현 확인 | 관로 노란색, 맨홀 보라색, 탭 명칭에 조사유형 표시 |
|
||||
| 관로 부분 PDF | 구현 확인 | `exmnPipeReport` WindowPopup과 Blob URL 정리 |
|
||||
| 일반 시설 회귀 | 정적 확인 | 조사 탭에서만 유지관리 영역을 숨김 |
|
||||
| 변경 공백 검사 | 통과 | 프론트·백엔드 `git diff --check` |
|
||||
| 전체 빌드·대상 테스트 | 미실행 | 장시간 실행 이력과 사용자 검증 방침에 따라 생략 |
|
||||
| 다중 시트 다운로드 | 구현 확인 | 종합 1 + 처리분구 상세 N, 본단위구간 제외 |
|
||||
| 지도 이동·행정구역 | 구현 확인 | 시도·시군구 query와 공통 검색 이벤트 연결 |
|
||||
| 지도 조사 레이어 | 구현 확인 | PPL 선·MNHL 점, 사업차수·구조·운영 등급 필터 |
|
||||
| 조사유형 시각 구분 | 구현 확인 | 관로 노란색, 맨홀 보라색, 탭 명칭에 조사유형 표시 |
|
||||
| 관로 부분 PDF | 구현 확인 | `exmnPipeReport` WindowPopup과 Blob URL 정리 |
|
||||
| 일반 시설 회귀 | 정적 확인 | 조사 탭에서만 유지관리 영역을 숨김 |
|
||||
| 변경 공백 검사 | 통과 | 프론트·백엔드 `git diff --check` |
|
||||
| 전체 빌드·대상 테스트 | 미실행 | 장시간 실행 이력과 사용자 검증 방침에 따라 생략 |
|
||||
|
||||
### 정적 보완 결과
|
||||
|
||||
@@ -573,30 +573,69 @@
|
||||
|
||||
## 35. 2026-07-31 관로 보고서 미등록 선분기 점검
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
|---|---|---|
|
||||
| 지도 응답 첨부 여부 | 정적 통과 | `reportAvailable` 속성 전달 |
|
||||
| 보고서 미등록 | 정적 통과 | WindowPopup 없이 alert 표시 |
|
||||
| 보고서 등록 | 정적 통과 | 기존 `exmnPipeReport` WindowPopup 호출 |
|
||||
| 부분 PDF 분석 실패 | 기존 동작 유지 | 팝업 내부 백엔드 오류 표시 |
|
||||
| 브라우저 수동 검증 | 대기 | 보고서 등록·미등록 사업을 각각 선택해 확인 |
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| ------------------- | -------------- | ------------------------------------------ |
|
||||
| 지도 응답 첨부 여부 | 정적 통과 | `reportAvailable` 속성 전달 |
|
||||
| 보고서 미등록 | 정적 통과 | WindowPopup 없이 alert 표시 |
|
||||
| 보고서 등록 | 정적 통과 | 기존 `exmnPipeReport` WindowPopup 호출 |
|
||||
| 부분 PDF 분석 실패 | 기존 동작 유지 | 팝업 내부 백엔드 오류 표시 |
|
||||
| 브라우저 수동 검증 | 대기 | 보고서 등록·미등록 사업을 각각 선택해 확인 |
|
||||
|
||||
## 36. 2026-08-06 지도보기 처리분구 선택 조건 점검
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
|---|---|---|
|
||||
| 처리분구 미선택 | 정적 통과 | `지도보기` 비활성화 및 선택 안내 title 표시 |
|
||||
| 처리분구 행 선택 | 정적 통과 | 선택 행 기준 버튼 활성화 |
|
||||
| 지도 이동 조건 | 정적 통과 | `trtdstId`, `trtdstNm` 필수 전달 및 함수 내부 방어 조건 적용 |
|
||||
| 합계 행 | 정적 통과 | 클릭 선택 이벤트가 없어 지도 대상이 되지 않음 |
|
||||
| 브라우저 수동 검증 | 대기 | 관로·맨홀 탭 각각에서 미선택·행 선택 상태 확인 필요 |
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| ------------------ | --------- | ------------------------------------------------------------ |
|
||||
| 처리분구 미선택 | 정적 통과 | `지도보기` 비활성화 및 선택 안내 title 표시 |
|
||||
| 처리분구 행 선택 | 정적 통과 | 선택 행 기준 버튼 활성화 |
|
||||
| 지도 이동 조건 | 정적 통과 | `trtdstId`, `trtdstNm` 필수 전달 및 함수 내부 방어 조건 적용 |
|
||||
| 합계 행 | 정적 통과 | 클릭 선택 이벤트가 없어 지도 대상이 되지 않음 |
|
||||
| 브라우저 수동 검증 | 대기 | 관로·맨홀 탭 각각에서 미선택·행 선택 상태 확인 필요 |
|
||||
|
||||
## 37. 2026-08-06 맨홀조사 선택 심볼 유지 점검
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
|---|---|---|
|
||||
| 실제 레이어 스타일 조회 | 정적 통과 | 선택기의 `getLayer(feature)`와 레이어 `getStyleFunction()` 연결 |
|
||||
| 점 시설 렌더링 순서 | 정적 통과 | 선택 원형 강조 후 원래 아이콘을 위에 렌더링 |
|
||||
| 선형 시설 회귀 | 정적 통과 | 기존 스타일 뒤에 선형 선택 강조를 추가하는 순서 유지 |
|
||||
| 변경 공백·구문 검사 | 통과 | `git diff --check`, `node --check` 수행 |
|
||||
| 브라우저 수동 검증 | 대기 | 맨홀 클릭 후 보라색 맨홀 심볼과 선택 원이 함께 유지되는지 확인 필요 |
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| ----------------------- | --------- | ------------------------------------------------------------------- |
|
||||
| 실제 레이어 스타일 조회 | 정적 통과 | 선택기의 `getLayer(feature)`와 레이어 `getStyleFunction()` 연결 |
|
||||
| 점 시설 렌더링 순서 | 정적 통과 | 선택 원형 강조 후 원래 아이콘을 위에 렌더링 |
|
||||
| 선형 시설 회귀 | 정적 통과 | 기존 스타일 뒤에 선형 선택 강조를 추가하는 순서 유지 |
|
||||
| 변경 공백·구문 검사 | 통과 | `git diff --check`, `node --check` 수행 |
|
||||
| 브라우저 수동 검증 | 대기 | 맨홀 클릭 후 보라색 맨홀 심볼과 선택 원이 함께 유지되는지 확인 필요 |
|
||||
|
||||
## 38. 2026-08-28 조사 입력양식 다운로드 검증
|
||||
|
||||
| 검증 항목 | 결과 | 근거 |
|
||||
| -------------------------- | ------ | ------------------------------------------------------- |
|
||||
| 저장 전 클릭 안내 | 통과 | `사업정보를 먼저 저장해주세요.` 안내 표출 확인 |
|
||||
| 등록 화면 사업별 양식 버튼 | 통과 | `양식다운로드` 버튼 0개 |
|
||||
| 등록 화면 공통 보고서 양식 | 통과 | `조사보고서 양식다운로드` 버튼 1개 유지 |
|
||||
| 수정 화면 사업별 양식 버튼 | 통과 | 저장 사업 `신사업`에서 `양식다운로드` 버튼 1개 |
|
||||
| 수정 화면 공통 보고서 양식 | 통과 | `조사보고서 양식다운로드` 버튼 1개 유지 |
|
||||
| 실제 파일 생성 | 통과 | `신사업_조사입력양식 (2).xlsx`, 188,261바이트 생성 |
|
||||
| 정적 검사 | 통과 | 전체 TypeScript, 대상 Prettier, `git diff --check` 통과 |
|
||||
| Next build | 미수행 | 사용자 실행 중인 3002 개발서버 보호 기준에 따라 생략 |
|
||||
|
||||
- API·DB 변경 없음. 판정: PASS.
|
||||
|
||||
## 39. 2026-08-28 사업등록·수정 양식 버튼 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| -------------------- | --------- | ---------------------------------------------------------------------- |
|
||||
| 사업등록 버튼 순서 | 정적 통과 | 양식다운로드 → 조사보고서 양식다운로드 → 저장 → 취소 |
|
||||
| 저장 전 입력양식 | 정적 통과 | 사업 ID가 없어 비활성 |
|
||||
| 저장 전 hover 안내 | 정적 통과 | 사업정보와 처리분구 저장 안내 표시 |
|
||||
| 신규 저장 후 팝업 | 정적 통과 | 부모 목록 갱신 메시지 전송, 팝업 유지·입력 잠금·양식 활성 |
|
||||
| 수정 버튼 순서 | 정적 통과 | 양식다운로드 → 저장 → 취소, 조사보고서 양식 미표시 |
|
||||
| 수정 후 양식 | 정적 통과 | 저장된 최신 처리분구 기준 재생성 |
|
||||
| 변경 파일 ESLint | 통과 | 오류·경고 0건 |
|
||||
| `sn` TypeScript 검사 | 통과 | `tsc --noEmit`, 기존 설정값은 명령줄 `ignoreDeprecations=5.0`으로 보정 |
|
||||
| 로그인 브라우저 | 대기 | 사업등록 저장 후 두 다운로드와 수정 팝업 버튼 3개 최종 확인 |
|
||||
|
||||
## 40. 2026-09-01 조사사업 공통코드 프론트 표시 빠른 검증
|
||||
|
||||
- 실제 CMS 캐시 API 확인
|
||||
- 결함등급 5건, 기록유형 2건, 결함분류 3건, 심각도 3건, 상태그룹 4건
|
||||
- 관로 조사항목 31건, 맨홀 조사항목 23건
|
||||
- 총 71개 코드가 계획한 그룹별 건수로 조회됐다.
|
||||
- `git diff --check`는 줄바꿈 변환 경고 외 whitespace 오류 없이 통과했다.
|
||||
- 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 수행하지 않았다.
|
||||
- 조사 종합집계 필터, 조사 상세표·Excel, 본단위등급표와 시설 상세의 코드명 수동 검증이 남아 있다.
|
||||
|
||||
@@ -333,3 +333,13 @@
|
||||
- TypeScript compiler API의 `transpileModule`로 `page.tsx`를 TSX 파싱한 결과 error diagnostics 0건이었다.
|
||||
- 프론트 변경 파일 대상 `git diff --check`는 통과했다.
|
||||
- 로그인 세션이 필요한 실제 탭 누적·교체·복귀 상호작용은 수동 검증이 남아 있다.
|
||||
|
||||
### 2.31 2026-09-01 지반침하 상태 공통코드 적용 빠른 검증
|
||||
|
||||
- 실제 CMS 캐시 API 확인
|
||||
- `SNKHLRSTRSTTS`: `RESTORED=복구완료`, `RESTORING=복구중`, `NOT_RESTORED=미복구`
|
||||
- `SNKHLCRDSTTS`: `SUCCESS=보정성공`, `FAILED=보정실패`
|
||||
- 백엔드와 기존 데이터가 복구상태를 `복구완료/복구중/미복구` 설명값으로 처리하는 계약을 코드·기존 검증 문서에서 확인했다.
|
||||
- `git diff --check`는 줄바꿈 변환 경고 외 whitespace 오류 없이 통과했다.
|
||||
- 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 수행하지 않았다.
|
||||
- 실제 지반침하 목록에서 검색조건·목록·Excel·상세 표시와 조회 결과 건수의 수동 검증이 남아 있다.
|
||||
|
||||
@@ -338,3 +338,46 @@ Chrome 확장의 파일 URL 접근 권한이 꺼져 있어 이번 회귀에서 E
|
||||
| 공통 선택 스타일 | 통과 | 별도 준설 CSS 없이 기존 `bg-accent !text-primary` 규칙 적용 |
|
||||
| 브라우저 콘솔 | 통과 | 새 검증 탭 경고·오류 0건 |
|
||||
| Next build | 미수행 | 사용자가 실행한 3002 개발 서버 보호 기준에 따라 생략 |
|
||||
|
||||
## 14. 2026-08-28 일반 HTTP 준설 등록 팝업 회귀검증
|
||||
|
||||
| 검증 항목 | 결과 | 근거 |
|
||||
| --- | --- | --- |
|
||||
| 원격 오류 재현 | 통과 | `con.mind-one.co.kr:11302`에서 client-side exception과 `crypto.randomUUID is not a function` 확인 |
|
||||
| 비보안 컨텍스트 조건 | 통과 | LAN HTTP 개발주소에서 `isSecureContext=false`, `crypto` 미제공 확인 |
|
||||
| UUID fallback | 통과 | Web Crypto `getRandomValues`만 있는 경우와 `crypto`가 없는 경우 모두 UUID v4 정규식 통과 |
|
||||
| 로컬 HMR 팝업 | 통과 | 등록 건 1, 사진 업로드, 위치 지정, 지도 영역이 정상 렌더링되고 오류·경고 0건 |
|
||||
| 준설 직접 호출 제거 | 통과 | 기능 파일의 직접 호출을 제거하고 `lib/uuid.ts` 공통 함수 한 곳으로 집중 |
|
||||
| TypeScript | 통과 | SN 전체 `tsc --noEmit --ignoreDeprecations 5.0` 종료코드 0 |
|
||||
| Prettier·공백 검사 | 통과 | 변경 5개 파일 Prettier 및 `git diff --check` 통과 |
|
||||
| Next build | 미수행 | 사용자 실행 중인 3002 개발서버 보호 기준에 따라 생략 |
|
||||
|
||||
- 수정 전 원격 증빙: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/20260828/01-원격HTTP-randomUUID-오류.png`.
|
||||
- 수정 후 로컬 증빙: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/20260828/02-로컬-수정후-준설등록-정상.png`.
|
||||
- 원격 11302 서버는 아직 수정 코드가 배포되지 않았으므로 배포 후 같은 URL의 최종 회귀검증이 필요하다.
|
||||
|
||||
## 15. 2026-08-28 등록·수정 촬영일시 날짜 표출 검증
|
||||
|
||||
| 검증 항목 | 결과 | 근거 |
|
||||
| --- | --- | --- |
|
||||
| ISO 일시 | 통과 | `2026-06-20T15:47:45 → 2026-06-20` |
|
||||
| 공백 구분 일시 | 통과 | `2026-07-18 16:12:57 → 2026-07-18` |
|
||||
| 날짜 단독 | 통과 | `2026-02-14 → 2026-02-14` |
|
||||
| 잘못된 날짜·빈 값 | 통과 | `2026-02-30`, null, 빈 문자열은 fallback 처리 |
|
||||
| 등록 화면 | 통과 | 사진 표의 촬영일시 cell과 title에 공통 날짜 포맷 함수 적용 |
|
||||
| 수정 화면 | 통과 | 기존 사진 3건이 `2026-06-20`, `2026-07-18`로 표출되고 시간 미표시 확인 |
|
||||
| 저장 계약 | 통과 | `clientCapturedAt`에는 기존 원본 일시를 그대로 전달 |
|
||||
| 정적 검사 | 통과 | 전체 TypeScript, 대상 Prettier, `git diff --check` 통과 |
|
||||
|
||||
- 검증 화면: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/20260828/03-수정화면-촬영일자-날짜만.png`.
|
||||
- 사용자 실행 중인 3002 개발서버를 보호하기 위해 Next build는 수행하지 않았다.
|
||||
|
||||
## 16. 2026-09-01 준설 공통코드 적용 빠른 검증
|
||||
|
||||
- 실제 CMS 캐시 API 확인
|
||||
- `DRDGNREGMODE`: `PHOTO=사진등록`, `MANUAL=수기등록`
|
||||
- `DRDGNLOCSRC`: `EXIF=사진 EXIF`, `MANUAL=수기지정`
|
||||
- `DRDGNPHOTOSTTS`: `COMPLETE=메타정보정상`, `MISSING_BOTH=촬영일시·GPS누락`, `MISSING_DATE=촬영일시누락`, `MISSING_GPS=GPS누락`
|
||||
- `git diff --check`는 줄바꿈 변환 경고 외 whitespace 오류 없이 통과했다.
|
||||
- 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 수행하지 않았다.
|
||||
- 등록·수정 팝업에서 버튼명, 위치출처, 사진상태 표시와 기존 저장 흐름의 수동 검증이 남아 있다.
|
||||
|
||||
@@ -32,3 +32,54 @@
|
||||
- 입력 중인 필터를 바로 읽던 기존 흐름 대신 `조회` 시점에만 적용 조건을 갱신하므로, 미적용 초안 필터 때문에 상세 팝업 결과 수나 페이지 상태가 흔들리던 회귀를 방지한다.
|
||||
- 상세 팝업은 페이지당 10건과 별도 테이블 스크롤 영역으로 조정되어 현재 팝업 높이에서 페이지네이션이 화면 하단에 노출되는 구조로 바뀌었다.
|
||||
- 브라우저 실동작 최종 확인은 사용자 수동 검증 기준으로 진행한다.
|
||||
|
||||
## 8. 2026-08-26 대구광역시 서구 브라우저 검증
|
||||
|
||||
- 브라우저에서 `관로분석 > 대구광역시 > 서구 > 조회`를 직접 실행했다.
|
||||
- 처리구역 선택지에 `달서천처리구역`, `북부처리구역`이 표시됐다.
|
||||
- 결과표에 전체 656.701km, 달서천처리구역 413.045km, 북부처리구역 243.656km와 경과년도 구간별 값이 표시됐다.
|
||||
- `재질별`은 처리구역 2행, `경과년도&재질`은 8행이 렌더링됐고 두 화면 모두 브라우저 오류 로그가 없었다.
|
||||
- 검증 화면: `COMMON_DOCS_ROOT/작업내용/SFR-011-002-관로분석/검증/20260826-대구광역시-서구-조회결과.jpg`
|
||||
- 변경 파일 TypeScript 구문검사 0건, Prettier와 `git diff --check` 통과.
|
||||
- SN 전체 타입검사는 동시 작업 파일 `cty-fldng-prvtn-biz-excel.ts`의 선행 타입 오류 3건으로 차단됐으며, 이번 관로분석 변경 파일에서는 오류가 확인되지 않았다.
|
||||
- 판정: 화면 표출 PASS.
|
||||
|
||||
## 9. 2026-08-26 관로현황 용도·관재질 검색조건 검증
|
||||
|
||||
- 용도 선택지: 전체, 미분류, 합류관, 차집관, 우수관, 오수관, 기타의 7개를 브라우저에서 확인했다.
|
||||
- 관재질 선택지: 공통 코드 193개와 전체를 합한 194개를 확인했다.
|
||||
- 전국 API 실데이터: 미분류 8,577건, 기타 5,921건.
|
||||
- 대구광역시 서구 브라우저 조회: 미분류 51건 중 10건 표출, 관재질 `MOP012` 127건 중 10건 표출.
|
||||
- 브라우저 오류 로그 없이 필터가 API 요청과 결과표에 반영됐다.
|
||||
- 검증 화면: `COMMON_DOCS_ROOT/작업내용/SFR-011-002-관로분석/검증/20260826-관로현황-용도미분류-조회.jpg`
|
||||
- SN 전체 TypeScript, Prettier, `git diff --check` 통과.
|
||||
- 판정: PASS.
|
||||
|
||||
## 10. 2026-08-26 관로현황 최상위 지도창 포커스 검증
|
||||
|
||||
- 실제 Chrome에서 `하수관망도 → 관로분석 → 재질별 결과 → 관로현황`의 3단계 창 계층을 구성했다.
|
||||
- 관로 `118529`, `118530` 행 클릭 시 최상위 지도창에서 해당 관로 이동·강조와 우측 상세정보 갱신을 확인했다.
|
||||
- 상세창 직접 `focus()`만 사용하면 Chrome에서 상세창이 활성 상태로 남았고, 상세창 `blur()`만 추가하면 중간 관로분석창이 활성화되는 차이를 확인했다.
|
||||
- 최종 구현은 상세창·중간창 `blur()`와 이름 기반 지도창 `focus()`, 지도창 메시지 처리 전후 self-focus를 함께 적용했다.
|
||||
- 자동화 제어는 창 전환 순간 기존 상세창 제어가 종료되어 OS 전경창 최종 상태의 재확인이 제한됐다. 실제 사용자 클릭 기준 수동 포커스 확인은 남은 검증 항목이다.
|
||||
- SN 전체 TypeScript와 `git diff --check` 통과. `map/page.tsx` 전체 Prettier는 기존 파일의 선행 포맷 차이로 통과하지 않았고 이번 추가 구문은 기존 스타일에 맞췄다.
|
||||
|
||||
## 11. 2026-08-26 관로 상세 유하방향 근거 검증
|
||||
|
||||
- 수정 전 Chrome 지도 상세에서 관로 `118529`의 유하방향 근거가 `-`로 표출되는 것을 확인했다.
|
||||
- 같은 관로의 유하방향 포함 API 응답은 `flowDirectionBasis=GIS 작도순서`, `flowDirectionBasisCode=GEOMETRY_FALLBACK`, `flowDirectionRuleLabel=geometry 시점->종점 fallback`을 반환했다.
|
||||
- 관로현황 행과 보강 API 응답의 병합 결과에 위 세 필드가 유지되는 것을 확인했다.
|
||||
- `map/page.tsx` 변경 구문 TypeScript 파서와 `git diff --check`는 통과했다.
|
||||
- SN 전체 TypeScript는 동시 변경된 기존 `event.source.postMessage` 타입 오류 1건으로 차단됐으며 이번 유하방향 보강 코드의 추가 오류는 없었다.
|
||||
- Chrome 입력 제어가 관로행 클릭 단계에서 시간 초과되어 수정 후 최종 화면 표출은 사용자 수동 확인 항목으로 남긴다.
|
||||
|
||||
## 12. 2026-08-28 하수관로 상세 공통 보강 재검증
|
||||
|
||||
- 재현 관로: 시설관리번호 `2015303665`, 관리기관코드 `5690000`, FID `tb_ms_swage_ppl.1135978`.
|
||||
- 실 API 응답: `flowDirectionBasis=시종점 관저고`, `flowDirectionBasisCode=DEPTH`, `flowDirectionRuleLabel=맨홀 표고-관로 깊이 관저고 높은 쪽->낮은 쪽`, 조회건수 1건.
|
||||
- 로컬 브라우저에서 같은 관로를 지도 상세 흐름으로 열어 우측 기본정보의 `유하방향 근거`가 `시종점 관저고`로 표시되는 것을 확인했다.
|
||||
- 검증 화면: `COMMON_DOCS_ROOT/작업내용/SFR-012-005-하수관로유하방향표출/검증/20260828-관로-2015303665-유하방향근거.png`.
|
||||
- 변경 컴포넌트 Prettier와 TypeScript 구문검사, `git diff --check` 통과.
|
||||
- SN 전체 TypeScript는 기존 `map/page.tsx:3659`의 `postMessage` 타입 오류 1건만 남았고 이번 공통 보강 코드의 추가 오류는 없다.
|
||||
- ESLint는 설치 상태에서 `eslint-plugin-react-hooks`를 찾지 못해 실행 환경 오류로 완료하지 못했다.
|
||||
- 판정: PASS.
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
- 분석기준시간 라벨·값이 현재 탭 행 오른쪽 끝에 표시되고 검색패널에서 제거된 것을 정적 확인했다.
|
||||
- 행 클릭 성공 안내문 제거와 오류 메시지 조건부 렌더링을 확인했다.
|
||||
- 보령 동대궁촌의 목록 최대 침수유량과 지도카드 값이 모두 `121.48m³/s`로 일치함을 실 API로 확인했다.
|
||||
- 지도카드 위험/정상 판정 설명과 `capacity > 0.8` 서비스 기준이 일치한다.
|
||||
- 지도카드 안내는 GIS 매핑 관로의 `Capacity >= 0.8` 지역 위험 기준만 표시한다.
|
||||
- 페이지별 데이터 길이·스크롤바에 따른 전체표 확대축소를 제거하고 `1320px` 고정폭·10개 열 비율·긴 행정명 말줄임 처리를 확인했다.
|
||||
- 팝업 컴포넌트에 `window.resizeTo` 호출이 없고 창 크기는 `popupDefinitions`의 `1380×900`으로만 결정되는 것을 정적 확인했다. 검색·결과영역의 overflow 숨김과 결과영역 좌우 여백 보정은 유지한다.
|
||||
|
||||
@@ -192,3 +192,23 @@
|
||||
- 과거 종료결과에서 실제 `2026-07-01 01:00`, `03:00`, `05:00` 등의 분석기준시간이 반환됐다.
|
||||
- 과거 종료결과 전체의 상단 최신 분석기준시간은 `2026-07-06 08:00`으로 반환됐다.
|
||||
- Java Service 테스트와 FE 대상 ESLint가 통과했다.
|
||||
|
||||
## 14. 2026-08-26 과거분석이력 최신순·중복 키 검증
|
||||
|
||||
- 정렬 파라미터가 없는 기본 요청도 백엔드 `RAIN_BGNG_DT DESC`에 따라 2026-08-26 07:00, 2026-08-25 13:00, 10:00, 06:00 순으로 반환된다.
|
||||
- 2026-08-25 경기 가평 지역 2건은 기존 키가 1개로 충돌했지만 강우시작시간·분석기준시간을 포함한 신규 키는 2개 모두 고유하다.
|
||||
|
||||
## 15. 2026-08-31 초단기예보격자망 자동 활성화 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
|---|---|---|
|
||||
| 통수위험 선택 메시지 연계 | 정적 통과 | 유효한 결과행·mapView 수신 후 체크 요청 생성 |
|
||||
| 기존 체크 상태 보존 | 정적 통과 | 요청 레이어를 기존 배열에 추가하는 방식 |
|
||||
| 동일 기능 재실행 | 정적 통과 | 행 선택마다 `requestKey` 증가 |
|
||||
| 트리 가시성 | 정적 통과 | 대상 노드와 `강우관련` 조상 그룹 펼침 |
|
||||
| 실제 레이어 ON | 정적 통과 | 공통 `checkSpecificNode`의 지도 반영 경로 재사용 |
|
||||
| 공통 지도 패키지 빌드 | 통과 | `pnpm --filter @common/business_components run build` |
|
||||
| `sn` TypeScript 검사 | 통과 | `tsc --noEmit --incremental false`, `ignoreDeprecations=5.0` 명령줄 보정 |
|
||||
| 브라우저 수동 검증 | 대기 | 실제 결과 행 클릭 후 격자망 체크·WFS 표출 확인 필요 |
|
||||
|
||||
- Prettier 실행 파일은 현재 workspace에 설치되어 있지 않아 별도 포맷 검사는 수행하지 못했다.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# SFR-011-008 침수예측지도 격자 표시 검증계획
|
||||
|
||||
- 침수예측현황 행 클릭 시 기존 지도 extent 이동 확인
|
||||
- `grid-map` API의 시군구·분석시각 파라미터와 WFS `latc_cd` 필터 확인
|
||||
- 각 격자 중앙 `latc_cd`·침수위험도·현재/예측 누적강우 표시 확인
|
||||
- 행 재선택 시 기존 overlay 제거 및 신규 격자 갱신 확인
|
||||
- BE 단위 테스트·컴파일 및 SN 브라우저 콘솔 오류 확인
|
||||
@@ -293,3 +293,33 @@
|
||||
- 10초는 로컬 warm 11.2MB·16,670건 조건의 통과 결과이며 운영 SLA 보증이 아니다.
|
||||
- 증빙: `COMMON_DOCS_ROOT/작업내용/SFR-012-002-하수관망도 업로드/검증/20260807/10s/23-performance-summary.json`
|
||||
- 최종 데이터 대조: 같은 폴더의 `22-final-feature-comparison.json`
|
||||
|
||||
## 9. 2026-08-28 관리기관 콤보박스 목록 검증
|
||||
|
||||
- 개발서버 API 확인:
|
||||
- `GET /api/v1/map/gisUploadFacilityTypes?screenId=SN_MN010302`: 하수관로 시설유형 1건, HTTP 200.
|
||||
- `GET /api/v1/fcltppl/mngInsts`: 관리기관 160건, 빈 관리기관코드 0건, HTTP 200.
|
||||
- 수정 후 실제 Chrome 개발서버 팝업에서 관리기관 콤보박스 161개 옵션(`선택` 1개 + 관리기관 160개)을 확인했다.
|
||||
- `가평군(4160000)`을 선택한 뒤 GIS 업로드 이력 목록과 다운로드·조회 버튼 활성화를 확인했다.
|
||||
- Prettier, SN 전체 TypeScript, `git diff --check`를 통과했다.
|
||||
- 사용자 소유 개발서버 3002·8883은 종료·재시작하지 않았고 `next build`는 수행하지 않았다.
|
||||
- 증빙: `COMMON_DOCS_ROOT/작업내용/SFR-012-002-하수관망도 업로드/검증/20260828/01-GIS관리-관리기관-가평군-선택.png`.
|
||||
- 판정: PASS.
|
||||
|
||||
## 10. 2026-08-28 GIS 관리 목록 버튼 문구 검증
|
||||
|
||||
- 기본 GIS 업로드 목록 액션에서 `목록` 버튼 표출을 확인했다.
|
||||
- 오류 확인 화면에서도 같은 버튼이 `목록`으로 표시됐다.
|
||||
- 오류 화면의 `목록` 클릭 시 `GIS 업로드 목록`으로 정상 복귀했다.
|
||||
- 전체 TypeScript, 대상 Prettier와 `git diff --check`를 통과했다.
|
||||
- API·DB 변경 없음. 판정: PASS.
|
||||
|
||||
## 11. 2026-09-01 GIS 관리 공통코드 적용 빠른 검증
|
||||
|
||||
- 실제 CMS 캐시 API 확인
|
||||
- `GISULDSTTS`: `COMPLETED=업로드완료`, `VALIDATION_FAILED=검증실패`, `FAILED=업로드실패`
|
||||
- `GISDWNLDSTTS`: `QUEUED=작업대기`, `RUNNING=파일생성중`, `READY=다운로드준비완료`, `FAILED=파일생성실패`
|
||||
- `SNFCLTSE`: `PPL=관로`, `MNHL=맨홀`
|
||||
- `git diff --check`는 줄바꿈 변환 경고 외 whitespace 오류 없이 통과했다.
|
||||
- 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 이번 단계에서 수행하지 않았다.
|
||||
- 실제 GIS 관리 팝업에서 시설구분명, 다운로드 상태 안내와 업로드 결과 fallback을 확인하는 수동 검증이 남아 있다.
|
||||
|
||||
@@ -759,3 +759,18 @@
|
||||
- 사용자가 실제 지도에서 GIS 편집 두 번째 클릭 시 하단 도구바와 인라인 세션이 함께 사라지는 것을 확인했다.
|
||||
- 다시 GIS 편집 진입 시 기존 변경분과 인라인 세션이 복원되는 것을 확인했다.
|
||||
- 결론: 추가 회귀 보정까지 사용자 수동 검증 완료.
|
||||
|
||||
### 2.32 2026-09-01 GIS 편집작업 공통코드 1차 적용 검증
|
||||
|
||||
- 실제 CMS 캐시 API 확인
|
||||
- `GISEDITOPRT`: `ADD=추가`, `MOVE=이동`, `VERTEX_EDIT=꼭지점편집`, `DELETE=삭제` 4건 확인
|
||||
- `GISEDITTYPE`: `C=등록`, `U=수정`, `D=삭제` 3건 확인
|
||||
- 전체 TypeScript 검사
|
||||
- 명령: `node apps/sn/node_modules/typescript/bin/tsc -p apps/sn/tsconfig.json --noEmit --pretty false --ignoreDeprecations 5.0`
|
||||
- 결과: 통과
|
||||
- SN 프로덕션 빌드
|
||||
- Next.js 최적화 컴파일과 정적 페이지 28개 생성까지 통과했다.
|
||||
- standalone traced file 복사 단계에서 Windows symlink `EPERM`으로 최종 종료코드 1이 발생했다.
|
||||
- TypeScript 또는 이번 변경 코드의 컴파일 오류는 아니다.
|
||||
- `git diff --check`는 줄바꿈 변환 경고 외 whitespace 오류 없이 통과했다.
|
||||
- 실제 지도 화면에서 툴바와 인라인·팝업 세션 라벨이 CMS 응답으로 표시되는지는 로컬 배포 또는 개발 서버 기동 후 수동 확인이 남아 있다.
|
||||
|
||||
@@ -24,3 +24,12 @@
|
||||
- 시트명과 B6 `순번`, D6 업무별 사업코드 헤더, B8 데이터 시작 위치를 자동 확인했다.
|
||||
- SN 개발 서버가 3002 포트에서 실행 중이어서 앱 규칙에 따라 `next build`는 실행하지 않았다.
|
||||
- 로그인·실 API 환경에서 조회조건 다운로드와 다운로드→수정→업로드 브라우저 검증은 남은 통합검증이다.
|
||||
|
||||
## 2026-09-01 운영사업 공통코드 화면 적용 빠른 검증
|
||||
|
||||
- 실제 CMS에서 사업구분 2건, 증빙상태 4건, 사업추진상태 7건을 확인했다.
|
||||
- 개발 DB에서 사업구분 `정밀조사 130건/정비사업 391건`, 추진현황의 `준공·공사중·설계중·발주중` 등 비표준 기존값, 증빙의 `O/X/공단/요청/준비중` 값을 확인했다.
|
||||
- 기존값 보존을 위해 추진·증빙 입력은 자유문자 datalist 방식으로 적용했다.
|
||||
- `git diff --check`는 whitespace 오류 없이 통과했다.
|
||||
- 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 수행하지 않았다.
|
||||
- 실제 목록·등록·수정에서 선택 후보와 기존값 저장 회귀 확인이 남아 있다.
|
||||
|
||||
@@ -9,3 +9,12 @@
|
||||
- `/oper/ctyFldngPrvtnBiz/register/1`: HTTP 200
|
||||
- 0811 원본 구조 확인: `사업 추진 현황` 시트, 상세 시작 `AJ/BK/CS/DX`, 결산 2013~2026년, 데이터 257행을 확인했다.
|
||||
- 실제 API·DB 저장과 브라우저 조작, 원본 파일 업로드 후 DB 대조는 테이블 및 인증 환경에서 추가 확인이 필요하다.
|
||||
|
||||
## 2026-09-01 운영사업 공통코드 화면 적용 빠른 검증
|
||||
|
||||
- 실제 CMS에서 도시침수 진행단계 4건, 지정유형 2건, 사업추진상태 7건을 확인했다.
|
||||
- 개발 DB에서 상세단계 `EM/MP/DD/CW` 각 257건, 지정유형 `우려 4건/피해 253건`을 확인했다.
|
||||
- 추진현황에는 표준 6개 값 외 `완료(연월)` 변형이 다수 있어 자유문자 datalist로 적용했다.
|
||||
- `git diff --check`는 whitespace 오류 없이 통과했다.
|
||||
- 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 수행하지 않았다.
|
||||
- 실제 목록·등록·수정에서 구역 제목, 입력 후보와 기존 변형값 유지 확인이 남아 있다.
|
||||
|
||||
@@ -91,3 +91,41 @@
|
||||
- 노후관로 요청에는 `includeFlowDirectionBasis`가 없고, 정보검색 기본정보의 하수관로 요청에만 `true`가 전달되는 것을 정적으로 확인했다.
|
||||
- 따라서 노후관로 정렬·페이지 이동마다 유하방향 근거 CTE 배치 조회가 추가 실행되지 않는다.
|
||||
- 실제 응답시간 비교는 변경된 백엔드 재기동 후 사용자 통합 환경에서 확인한다.
|
||||
|
||||
## 11. 2026-08-26 유지관리 대메뉴 검증
|
||||
|
||||
- `@common/business_components` tsup ESM/DTS 빌드: 성공.
|
||||
- SN TypeScript `tsc --noEmit`: 성공.
|
||||
- sewerage-service `compileJava`: 성공.
|
||||
- 사용자 실행 SN 개발 서버를 보호하기 위해 Next build는 수행하지 않았다.
|
||||
- 조사 목록의 사업명 서버 정렬은 11건을 10건·1건의 2페이지로 조회했고 asc/desc 변경 시 첫 페이지 복귀를 확인했다.
|
||||
- 조사 상세현황은 합계 행을 고정한 채 처리분구명 `덕풍↔초이` 순서 전환을 확인했다.
|
||||
- 관로조사 상세 팝업은 9개 고정 열 정렬 버튼과 보고서번호 asc/desc, 소계·합계 고정을 확인했다.
|
||||
- 준설 지자체별 13개·관경별 9개 정렬 헤더와 관재질 표시명 asc/desc를 확인했다.
|
||||
- 처리분구 목록은 4개 정렬 헤더와 처리분구명 asc/desc/default 순환을 확인했다.
|
||||
- 정렬 아이콘은 미선택 흰색, 선택 노란색이며 한 단계 확대된 상태를 실제 화면에서 확인했다.
|
||||
- 팝업 콘솔 신규 오류는 0건이다. 메인 화면의 Radix `aria-controls` hydration mismatch는 기존 공통 경고로 분리한다.
|
||||
- FO·WR 화면은 수정·브라우저 적용하지 않았다.
|
||||
|
||||
## 12. 2026-08-26 운영정보·I/I 분석 검증
|
||||
|
||||
- SN TypeScript `tsc --noEmit`: 성공.
|
||||
- 도시침수예방사업 23개 직접 필드 정렬 헤더와 시군구 asc/desc를 확인했다.
|
||||
- 노후 하수관로 정비사업 22개 정렬 헤더와 시군구 `가평군...` asc, `화순군...` desc를 확인했다.
|
||||
- 침입수 분석결과는 2025-01~2026-08 범위 48건·5페이지, 7개 헤더와 분석정보 asc/desc를 확인했다.
|
||||
- RDII 분석결과는 같은 기간 20건·2페이지, 9개 헤더와 기여면적 sort 요청을 확인했다.
|
||||
- I/I 시도·시군구·분석자는 정렬 버튼이 노출되지 않는 것을 확인했다.
|
||||
- MapPopup 정렬 아이콘이 유지관리와 같은 미선택 흰색·선택 노란색 스타일을 사용하는 것을 확인했다.
|
||||
- 운영정보 등록·수정과 I/I 분석 입력 편집표는 정렬 대상에서 제외했다.
|
||||
- FO·WR는 수정·검증 범위에서 제외했다.
|
||||
|
||||
## 13. 2026-08-26 운영정보 Excel 생성 수정 검증
|
||||
|
||||
- 사용자 첨부 도시침수·노후관로 생성본과 확정 원본 양식을 값·시트·렌더링 이미지로 비교했다.
|
||||
- SN TypeScript `tsc --noEmit --ignoreDeprecations 5.0`: 성공.
|
||||
- 브라우저에서 도시침수 19건과 노후관로 정밀조사 128건을 실제 재다운로드했고 콘솔 오류는 0건이었다.
|
||||
- 도시침수 첫 데이터 순번이 `1, 2, 3`이고 제목·요약·5단 헤더·본문 테두리·고정 행 높이가 렌더링되는 것을 확인했다.
|
||||
- 노후관로 두 시트의 제목·요약·다단 병합 헤더·구분색과 22pt 본문 높이를 확인했다.
|
||||
- 계획연장 합계는 `30,381.602`로 반올림됐고 긴 텍스트의 `_x000d_` 표기는 실제 줄바꿈으로 변환됐다.
|
||||
- 두 최종 파일의 수식 오류 토큰 검색 결과는 0건이었다.
|
||||
- 사용자 실행 개발 서버를 보호하기 위해 Next 전체 빌드는 수행하지 않았다.
|
||||
|
||||
@@ -336,3 +336,32 @@
|
||||
- 지도 피처 선택 강조는 조사유형 색과 분리하고 기존 공통 지도 선택 효과를 유지한다.
|
||||
- 관로 PDF는 새 브라우저 탭이 아니라 `exmnPipeReport` WindowPopup 안에서 표시한다.
|
||||
- 최종 완료 판정 전에는 지도 관로·맨홀 동시 ON/OFF, 필터 변경, 시설 선택, 관로 PDF 성공·실패 흐름의 로그인 브라우저 회귀가 필요하다.
|
||||
|
||||
## 26. 2026-08-28 사업등록·수정 양식 버튼 계약
|
||||
|
||||
- 사업등록 진입은 `양식다운로드 → 조사보고서 양식다운로드 → 저장 → 취소` 순서를 사용한다.
|
||||
- `양식다운로드`는 사업 저장 전 비활성이고, 저장 성공 후 팝업을 유지한 상태에서 처리분구별 입력양식을 생성·다운로드한다.
|
||||
- 저장 전 비활성 양식다운로드에 마우스를 올리면 `사업정보와 처리분구를 먼저 저장해주세요.` 안내를 표시한다.
|
||||
- `조사보고서 양식다운로드`는 사업 저장 여부와 무관하게 공통 원본 XLSX를 내려받는다.
|
||||
- 수정 진입은 `양식다운로드 → 저장 → 취소`만 표시하고 조사보고서 공통 양식 버튼은 숨긴다.
|
||||
- 수정 화면의 양식다운로드는 저장된 최신 처리분구 기준으로 입력양식을 다시 생성한다.
|
||||
- 등록된 실제 사업보고서 PDF는 기존 조사목록의 보고서 열에서 다운로드하는 계약을 유지한다.
|
||||
|
||||
## 27. 2026-09-01 지도 조사 설정 카드 UI 통일 계획
|
||||
|
||||
- `ExmnLayerSettingPanel`의 관로조사·맨홀조사 카드를 침수흔적도 설정과 동일한 header/body 구조로 변경한다.
|
||||
- 제목 오른쪽의 액션 `div`에는 `적용 여부 토글 → 닫기(×)`를 배치하고 토글 기본값은 ON으로 한다.
|
||||
- 토글 OFF에서는 해당 조사유형의 사업차수·구조·운영 필터 적용을 해제하되 입력값은 유지하며, 닫기는 카드만 숨기고 레이어 체크·필터 상태를 유지한다.
|
||||
- 두 조사유형의 적용 여부, 닫기, 입력값은 독립 상태로 관리한다.
|
||||
- 카드 제목 `1.15rem`, 본문 `1.05rem`, header `32px`, 본문 padding `8px`, 선택 입력 `22px`, font `1rem`, 좌우 padding `4px`를 적용한다.
|
||||
- 관로 노랑·맨홀 보라 의미색은 indicator로 유지하고 카드 외곽·제목은 중립 스타일로 통일한다.
|
||||
- 상태등급 select는 침수흔적도 설정의 SelectBox와 같은 font·padding·border/background 스타일을 적용한다.
|
||||
- 기존 자동반영 동작을 유지하며 원래 없는 `✓` 버튼을 새로 추가하지 않는다. 지반침하·노후관로 등 기존 `✓`가 있는 카드의 버튼은 공통 변경으로 제거하지 않는다.
|
||||
- `map/page.tsx`의 선택 레이어 설정 header에서는 활성 카드 개수와 카드명 pill/chip 목록을 제거하고 제목만 유지한다.
|
||||
- 검증은 관로·맨홀 카드 동시 표시, 독립 토글·닫기, 스타일 통일, 자동반영, 상단 개수·chip 미표시와 지도 필터 회귀를 포함한다.
|
||||
- API·DTO·DB 변경은 없다.
|
||||
|
||||
### 27.1 구현 결과
|
||||
|
||||
- `ExmnLayerSettingPanel`을 공통 카드 header·토글·닫기 구조로 변경하고 관로·맨홀 상태를 독립 관리한다.
|
||||
- 사업차수·상태등급의 기존 자동반영은 유지하며 토글 OFF에서는 빈 필터로 다시 조회한다.
|
||||
|
||||
@@ -273,3 +273,24 @@
|
||||
- 부모가 있는 관로 탭은 활성 여부와 무관하게 `←` 버튼을 항상 표시해 일반 관로와 구분하고 지반침하 상세로 복귀한다.
|
||||
- 공용 우측 탭은 최대 5개 FIFO를 적용하되, 제거 대상이 부모 지반침하이면 그 부모에서 열린 연계 관로도 함께 제거한다. 연계 관로만 제거될 때는 부모 지반침하를 유지한다.
|
||||
- 변경 범위는 `app/(map-layout)/map/page.tsx`이며 BE API·DTO·DB 계약은 변경하지 않는다.
|
||||
|
||||
## 16. 2026-09-01 선택 레이어 설정 지반침하 카드 UI 통일 계획
|
||||
|
||||
- 작업 모드: `maintenance`
|
||||
- 이번 단계는 작업계획만 수정하고 코드는 후속 승인 후 변경한다.
|
||||
- `SinkholeLayerSettingPanel`을 침수흔적도 설정 카드와 동일한 compact 카드 구조로 정리한다.
|
||||
- 헤더에 `지반침하정보` 제목을 표시하고 우측 액션 `div`에 `적용 여부 토글 → 닫기(×)` 순서로 배치한다.
|
||||
- 토글 기본값은 ON이며 OFF에서는 연도 조회조건을 제거하되 현재 연도·반경 입력값은 유지한다.
|
||||
- 닫기는 설정 카드만 숨기고 레이어와 현재 설정값은 유지한다.
|
||||
- 침수흔적도 설정과 동일하게 카드 header `32px`, 제목 `1.15rem`, 본문 `1.05rem`, 본문 padding `8px`, SelectBox `62×22px`, SelectBox font `1rem`·좌우 padding `4px`를 적용한다.
|
||||
- 주황 카드·제목 스타일은 제거하고 공통 border/background/label 색상으로 통일한다.
|
||||
- 반경과 년도설정의 `✓` 적용 버튼은 모두 유지하며 SelectBox 선택만으로 자동 적용하지 않는다.
|
||||
- `map/page.tsx`의 선택 레이어 설정 공통 header에서는 활성 카드 개수와 카드명 pill/chip 목록을 제거하고 `선택 레이어 설정` 제목만 유지한다.
|
||||
- 공통 상단 표시 제거는 지반침하·침수흔적도·조사시설·노후관로·침수심 범례 등 모든 설정 카드에 적용한다.
|
||||
- 검증은 토글/닫기 배치, 카드·SelectBox 스타일 일치, `✓` 적용 유지, 상단 개수·chip 미표시, 다중 카드 순서·스크롤 회귀를 포함한다.
|
||||
- API·DTO·DB 변경은 없다.
|
||||
|
||||
### 16.1 구현 결과
|
||||
|
||||
- `SinkholeLayerSettingPanel` compact 카드에 공통 header·토글·닫기와 침수흔적도 기준 입력 스타일을 적용했다.
|
||||
- 반경·년도 `✓` 적용 버튼은 유지했고 토글·닫기 상태는 `map/page.tsx`에서 관리한다.
|
||||
|
||||
@@ -208,3 +208,11 @@
|
||||
|
||||
- 요구사항정의서 93행은 합리식 `Q=CIA`와 Manning 공식을 요구하지만, 화면설계서 20쪽은 SWMM5 연계를 제시한다. 실제 산정 기능 설계 전에 적용 기준을 확정해야 한다.
|
||||
- 2026-08-04 실제 API 연동 시 `map/page.tsx`, 세션 타입, popup 메시지 타입에 남아 있던 설정 편집·staged 관로 호환 코드도 제거해 결과 조회와 지도보기 계약만 유지했다. 상세 구현·검증은 `002-fe-SFR-011-007-통수위험분석-실API연동및검증.md`를 따른다.
|
||||
|
||||
## 14. 2026-08-31 행 선택 시 초단기예보격자망 자동 활성화
|
||||
|
||||
- 원본 추적 근거는 요구사항정의서 `SFR-011-007` 93행과 화면설계서 21쪽 `SN_MN010202`의 결과 행 선택 후 GIS 위치 이동 흐름이다.
|
||||
- 사용자의 최신 지시에 따라 현재·과거분석이력 결과 행 선택으로 지도 결과를 표시할 때 시설물 레이어의 `강우관련 > 초단기예보격자망`을 자동으로 ON 한다.
|
||||
- 기존 사용자가 켜둔 다른 시설물 레이어는 유지하고 초단기예보격자망만 추가 체크한다.
|
||||
- 사용자가 격자망을 다시 끈 뒤 다른 결과 행을 선택해도 새 요청으로 다시 ON 한다.
|
||||
- 백엔드 API·DB·통수위험 mapView 응답은 변경하지 않는다.
|
||||
|
||||
@@ -31,12 +31,15 @@
|
||||
- `ListDiv` 내부에서 최소 테이블 폭과 가로 스크롤을 제공한다.
|
||||
- Excel은 현재 적용 검색조건을 동일하게 전달한다.
|
||||
- 신규 파일과 함수에 화면/API 역할 한글 주석을 작성한다.
|
||||
- 결과표는 `시도, 시군구, 분석시각, 강우시작시각, 관측 누적강수량, 예측시간, 예측 누적강수량, 초과 빈도(년), 침수위험도` 순서로 표시한다.
|
||||
- 예측시간은 `N시간 후`, 관측·예측 누적강수량은 소수점 한 자리로 표시한다.
|
||||
- 지도화면 분석도구는 공용 분석시각 범위와 `위험` 조건의 count API를 조회하여 `침수예측현황(x건)`으로 표시한다.
|
||||
|
||||
## 지도 연계
|
||||
|
||||
- 행 선택 시 기존 도시침수지도 group extent API를 호출한다.
|
||||
- 부모 창에 기존 `MAP_SEARCH_SHOW_FLOOD_GROUP` 메시지를 보내 범위 이동·레이어 표시를 요청한다.
|
||||
- 도시침수지도에 없는 빈도는 자동 변환하지 않고 화면 안내를 표시한다.
|
||||
- 도시침수지도 빈도는 30년 이하를 `030`, 100년 초과를 `100`으로 보정하고 그 외 미지원 빈도는 안내한다.
|
||||
|
||||
## 검증
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# SFR-011-008 침수예측지도 중앙 오버레이 표시 FE 작업계획
|
||||
|
||||
## 목적
|
||||
|
||||
침수예측현황 선택행으로 지도 이동 시 선택한 도시침수지도 중앙에 사각 HTML table 단일 오버레이를 표시한다. 제목은 `[시도] [시군구] 침수예측` 두 열 병합이며, 본문은 분석시각, 강우시작시각, 관측 누적강수량(mm), 예측시각, 예측 누적강수량(mm), 초과 빈도(년), 침수위험도 순서다. 격자별 라벨과 위치 마커는 표시하지 않는다.
|
||||
|
||||
## 구현 방법
|
||||
|
||||
- 현황 팝업 메시지에 `analysisDateTime`을 추가한다.
|
||||
- 지도 페이지에서 BE `grid-map` API를 호출한다.
|
||||
- 기존 도시침수지도 extent 중심 좌표에 위 9개 표시 항목을 table 행으로 렌더링한다.
|
||||
- 초단기예보격자망 WFS 폴리곤은 기존 레이어 표현을 유지한다.
|
||||
- 다음 선택 시 전용 중앙 overlay를 삭제하고 새 응답으로 갱신한다.
|
||||
|
||||
현재·예측 누적강우는 FE에서 격자별로 계산하지 않는다. BE가 반환하는 선택 시군구 분석행의 `obsrvn_acml_rn`, `predc_acml_rn`을 사용하며, 선택 도시침수지도 중앙의 단일 table overlay에 표시한다.
|
||||
|
||||
## 검증
|
||||
|
||||
- 슬라이드 4의 선택 시군구 도시침수지도 중앙 정보 배치
|
||||
- 다른 행 선택 시 중앙 overlay 교체
|
||||
- 닫기 버튼 클릭 시 overlay 제거
|
||||
- 기존 지도 extent 이동·침수위험 레이어 선택 유지
|
||||
- API 오류 시 기존 지도 이동 기능 유지
|
||||
@@ -0,0 +1,28 @@
|
||||
# SFR-011-009 FE 침수흔적도-깊이도 연계 작업계획
|
||||
|
||||
## 1. 상태
|
||||
|
||||
- 승인 대기
|
||||
- 구현 전 계획 문서
|
||||
- 범위: `apps/sn` 내부
|
||||
|
||||
## 2. 화면 계획
|
||||
|
||||
- 기존 침수흔적도 기본정보 상세 영역 우측에 침수깊이지도 연도 버튼 목록을 추가한다.
|
||||
- 버튼은 투명 배경, 테두리만 있는 라운드 형태로 기존 상세정보 UI와 조화되게 구현한다.
|
||||
- 선택 연도의 깊이도 항목은 기본정보 아래 별도 div와 table로 표시한다.
|
||||
- 상세 div 우측 상단에 `X` 닫기 버튼을 둔다.
|
||||
- 지도 시설물 클릭과 침수발생이력 선택에서 동일 상태 모델을 사용한다.
|
||||
- 현재 표시 중인 연도 버튼은 파란색으로 표시한다.
|
||||
- 연도가 많으면 좌우 화살표로 연도 버튼 목록을 가로 스크롤한다.
|
||||
- 연도 선택 시 해당 침수흔적도-깊이 feature를 지도에서 선택 상태로 표시한다.
|
||||
|
||||
## 3. 구현 후보
|
||||
|
||||
- `app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx`
|
||||
- `app/(map-layout)/map/components/fldngHstry/**`
|
||||
- 관련 침수흔적도 타입/API 호출부
|
||||
|
||||
## 4. 검증
|
||||
|
||||
- 연도 중복 제거·정렬, 선택 연도 파란색 상태, 좌우 스크롤, 연도 전환, 닫기, 빈 데이터, 지도 feature 선택, 양쪽 진입 경로 및 기존 상세정보 회귀를 브라우저에서 확인한다.
|
||||
+48
-22
@@ -14,20 +14,20 @@
|
||||
|
||||
## 3. 구현 라운드 요약
|
||||
|
||||
| 날짜 | 단계 | 결과 |
|
||||
| --- | --- | --- |
|
||||
| 2026-07-22 | FE-0 | 처리분구 조회·추가·명칭수정·삭제대기·일괄저장 UI와 타입/API 계약 구현 |
|
||||
| 2026-07-22 | FE-0 후속 | 메뉴 진입 시 발생한 Next 서버/클라이언트 import 경계 오류 수정 |
|
||||
| 2026-07-22 | FE-0 화면 마무리 | 공통 UiTable 정렬과 검색조건·버튼 배치/색상 보완 |
|
||||
| 2026-07-22 | FE-0 완료 | 2차 UI 보정 후 사용자 화면 검증 통과, FE-1 시작 대기 |
|
||||
| 2026-07-22 | FE-1 | 사업 목록·등록, 파일 검토/확정, PPL 집계·시설·단위상세 구현 |
|
||||
| 2026-07-22 | 화면 2단계 완료 | 조사사업 목록과 사업등록·조사결과 업로드 WindowPopup 사용자 확인 완료 |
|
||||
| 2026-07-23 | FE-1 조사현황 보정 | 목록·상세 가로 기준 통일, 사업 행 전체 선택, 목록·상세 내부 스크롤 통일 |
|
||||
| 2026-07-23 | 화면 3단계 완료 | 같은 화면의 관로·맨홀 종합집계, 합계, 내부 스크롤, 요청 순서 보호 사용자 확인 완료 |
|
||||
| 2026-07-23 | 팝업 회귀 복구 | Tailwind `src` 스캔 복구와 핵심 열·폭 인라인 고정 후 완료 디자인 재확인 |
|
||||
| 2026-07-24 | 상세보기 화면 완료 | 관로·맨홀 WindowPopup 다단표, 조회·정렬, 고정열, 소계·합계 사용자 확인 완료 |
|
||||
| 2026-07-27~29 | 실 API 통합 | 처리분구·사업·파일검토/저장·집계·전체/본단위/결함별 상세 API 연결 |
|
||||
| 2026-07-30 | 다운로드·샘플 제거 | 다중 시트 XLSX 다운로드 연결 및 샘플 전환·데이터·분기 제거 |
|
||||
| 날짜 | 단계 | 결과 |
|
||||
| ------------- | ------------------ | ---------------------------------------------------------------------------------- |
|
||||
| 2026-07-22 | FE-0 | 처리분구 조회·추가·명칭수정·삭제대기·일괄저장 UI와 타입/API 계약 구현 |
|
||||
| 2026-07-22 | FE-0 후속 | 메뉴 진입 시 발생한 Next 서버/클라이언트 import 경계 오류 수정 |
|
||||
| 2026-07-22 | FE-0 화면 마무리 | 공통 UiTable 정렬과 검색조건·버튼 배치/색상 보완 |
|
||||
| 2026-07-22 | FE-0 완료 | 2차 UI 보정 후 사용자 화면 검증 통과, FE-1 시작 대기 |
|
||||
| 2026-07-22 | FE-1 | 사업 목록·등록, 파일 검토/확정, PPL 집계·시설·단위상세 구현 |
|
||||
| 2026-07-22 | 화면 2단계 완료 | 조사사업 목록과 사업등록·조사결과 업로드 WindowPopup 사용자 확인 완료 |
|
||||
| 2026-07-23 | FE-1 조사현황 보정 | 목록·상세 가로 기준 통일, 사업 행 전체 선택, 목록·상세 내부 스크롤 통일 |
|
||||
| 2026-07-23 | 화면 3단계 완료 | 같은 화면의 관로·맨홀 종합집계, 합계, 내부 스크롤, 요청 순서 보호 사용자 확인 완료 |
|
||||
| 2026-07-23 | 팝업 회귀 복구 | Tailwind `src` 스캔 복구와 핵심 열·폭 인라인 고정 후 완료 디자인 재확인 |
|
||||
| 2026-07-24 | 상세보기 화면 완료 | 관로·맨홀 WindowPopup 다단표, 조회·정렬, 고정열, 소계·합계 사용자 확인 완료 |
|
||||
| 2026-07-27~29 | 실 API 통합 | 처리분구·사업·파일검토/저장·집계·전체/본단위/결함별 상세 API 연결 |
|
||||
| 2026-07-30 | 다운로드·샘플 제거 | 다중 시트 XLSX 다운로드 연결 및 샘플 전환·데이터·분기 제거 |
|
||||
|
||||
### 3.1 현재 구현 경계
|
||||
|
||||
@@ -706,11 +706,11 @@
|
||||
|
||||
### 화면·API 계약
|
||||
|
||||
| 구분 | 계약 |
|
||||
|---|---|
|
||||
| 지도 이동 | `/map?exmnBizId=...&ctpvNm=...&sggNm=...&exmnJobType=PPL|MNHL&bizCycl=...` |
|
||||
| 레이어 조회 | `/sewerage-service/api/v1/exmnMap/layers` |
|
||||
| 필터 | `jobType`, `sggCd`(앞 5자리), 행정구역명, 사업차수, 구조적 등급, 운영적 등급 |
|
||||
| 구분 | 계약 |
|
||||
| ----------- | ---------------------------------------------------------------------------- | ----------------- |
|
||||
| 지도 이동 | `/map?exmnBizId=...&ctpvNm=...&sggNm=...&exmnJobType=PPL | MNHL&bizCycl=...` |
|
||||
| 레이어 조회 | `/sewerage-service/api/v1/exmnMap/layers` |
|
||||
| 필터 | `jobType`, `sggCd`(앞 5자리), 행정구역명, 사업차수, 구조적 등급, 운영적 등급 |
|
||||
|
||||
### 2026-07-31 지도보기 초기 시군구 선택·대표 관로 확대 보완
|
||||
|
||||
@@ -718,9 +718,9 @@
|
||||
- 공통 지도 검색바는 외부 선택 이벤트가 시군구 원천 목록 조회보다 먼저 도착해도 목록 로딩 후 현재 시도에 맞는 시군구 후보를 다시 구성한다.
|
||||
- 최초 조사결과 레이어 조회 성공 시 진입한 조사유형의 첫 시설 한 건을 기준으로 최대 줌 18까지 한 번만 확대한다.
|
||||
- 이후 사용자의 지도 이동이나 필터 재조회에서는 초기 자동 확대를 반복하지 않는다.
|
||||
| 필터 결합 | 구조·운영 등급 동시 입력 시 `AND` |
|
||||
| 좌표계 | GeoJSON `EPSG:5179` |
|
||||
| 레이어 선택 | 기존 지도 선택 모듈에 PPL 선·MNHL 점 레이어 등록 |
|
||||
| 필터 결합 | 구조·운영 등급 동시 입력 시 `AND` |
|
||||
| 좌표계 | GeoJSON `EPSG:5179` |
|
||||
| 레이어 선택 | 기존 지도 선택 모듈에 PPL 선·MNHL 점 레이어 등록 |
|
||||
|
||||
### 수정 파일
|
||||
|
||||
@@ -934,3 +934,29 @@
|
||||
- Excel 데이터 내보내기는 `엑셀다운로드`, 입력 템플릿은 `양식다운로드`, 일반 첨부파일은 `다운로드`로 구분했다.
|
||||
- `StandardPopupLayout` 본문을 margin 기반 `space-y` 대신 `flex`와 `gap: 1.2rem`으로 변경했다. 자식 카드의 `!m-0` 때문에 간격이 0으로 덮이던 문제를 공통 레이아웃에서 해결했다.
|
||||
- 브라우저에서 조사사업 등록·수정의 `사업 정보`와 `사업대상 처리분구` 카드 사이 실제 간격이 모두 12px인 것을 확인했다.
|
||||
|
||||
## 45. 2026-08-28 조사 입력양식 다운로드 노출·Blob 처리 보완
|
||||
|
||||
- 사업별 조사 입력양식은 저장된 `exmnBizId`와 확정 처리분구가 있어야 생성할 수 있으므로 신규 등록 화면에서는 `양식다운로드` 버튼을 숨겼다.
|
||||
- 수정 화면에는 `양식다운로드`를 유지하고, 저장과 무관한 공통 `조사보고서 양식다운로드`는 등록·수정 모두 유지한다.
|
||||
- Blob 다운로드 링크를 DOM에 연결한 뒤 클릭하고 1초 후 object URL을 해제하도록 변경해 Chrome가 파일을 수락하기 전에 URL이 사라지는 문제를 방지했다.
|
||||
- 사업·처리분구 저장, 양식 생성 API와 DB 계약은 변경하지 않았다.
|
||||
|
||||
## 46. 2026-08-28 사업등록·수정 양식 버튼 분리
|
||||
|
||||
- 신규 사업등록 저장 성공 시 부모 목록에 `EXMN_BUSINESS_CREATED` 메시지를 전달하되 팝업은 닫지 않도록 변경했다.
|
||||
- 저장 후 기존 `savedBusiness`와 `formLocked`를 사용해 입력은 잠그고 `양식다운로드`를 활성화한다.
|
||||
- 저장 전 비활성 버튼은 hover 가능한 래퍼에서 `사업정보와 처리분구를 먼저 저장해주세요.` 안내를 제공한다.
|
||||
- 사업등록 진입에서는 `양식다운로드`, `조사보고서 양식다운로드`, `저장`, `취소` 순서로 표시한다.
|
||||
- 수정 진입에서는 `조사보고서 양식다운로드`를 숨기고 `양식다운로드`, `저장`, `취소`만 표시한다.
|
||||
- 처리분구를 수정한 경우 기존 계약대로 먼저 저장한 뒤 최신 처리분구 기준 입력양식을 다시 생성한다.
|
||||
- 공통 조사보고서 XLSX와 등록된 사업보고서 PDF의 다운로드 경로는 변경하지 않았다.
|
||||
|
||||
## 47. 2026-09-01 조사사업 공통코드 프론트 표시 1차 적용
|
||||
|
||||
- `EXMNDFCGRD`, `EXMNRECDTYPE`, `EXMNDFCTYPE`, `EXMNDFCSEV`, `EXMNSTTSGRP`, `PPLEXMNITEM`, `MNHLEXMNITEM`을 기존 CMS 캐시 API로 조회하고 브라우저 세션에서 그룹별 응답과 진행 중 요청을 재사용한다.
|
||||
- PPL 시설 필터의 구조·운영등급 선택지와 결함분류 표시, 조사 상세표·Excel의 결함분류·심각도·관로·맨홀 항목명에 CMS 코드명을 적용했다.
|
||||
- 본단위등급표와 PPL 시설 상세의 기록유형·결함분류·심각도·상태그룹 표시에도 같은 코드명을 적용했다.
|
||||
- 조사 코드값, 상세표 열 순서, API 파라미터와 백엔드 Excel 파서·분류 목록은 변경하지 않았다.
|
||||
- CMS 조회 실패 또는 미등록 코드에는 기존 화면 라벨과 서버 제공값을 fallback으로 유지한다.
|
||||
- 백엔드 API와 DB는 변경하지 않았다.
|
||||
|
||||
@@ -473,6 +473,7 @@
|
||||
- `SinkholePopupPage.tsx` 그리드 첫 데이터 열에 `사고번호(acdntNo)`를 배치하고 화면 `NO` 열은 제거했다. 내부 `rowNo`와 다운로드 순번은 유지했다.
|
||||
- 반경 내 관로 GeoServer 조회의 기존 storage nullable 타입 경고는 저장소 이름 확인 후 요청하도록 방어 처리했다.
|
||||
- BE API·DTO·Repository·DB 변경은 없다.
|
||||
|
||||
- 브라우저·API·빌드 검증은 사용자 요청에 따라 수행하지 않았으며 사용자 검증 대기 상태다.
|
||||
|
||||
### 4.55 2026-07-14 목록 NO 열 제거 후속 반영
|
||||
@@ -499,3 +500,13 @@
|
||||
- 부모가 있는 지반침하 연계 관로는 비활성 상태에서도 `←`를 항상 표시해 일반 관로와 구분되도록 후속 보완했다.
|
||||
- 탭 최대 5개 초과 시 가장 먼저 열린 탭을 제거하는 FIFO를 유지하고, 제거 대상이 부모 지반침하이면 그 부모를 참조하는 연계 관로 탭도 함께 제거하도록 공통 그룹 제거 함수를 적용했다. 연계 관로 단독 제거는 부모에 영향을 주지 않는다.
|
||||
- BE API·DTO·Repository·DB 변경은 없다.
|
||||
|
||||
### 4.58 2026-09-01 지반침하 상태 공통코드 적용
|
||||
|
||||
- `SNKHLRSTRSTTS`, `SNKHLCRDSTTS`를 기존 CMS 캐시 API로 조회하고 브라우저 세션에서 그룹별 응답과 진행 중 요청을 재사용한다.
|
||||
- 좌표보정상태는 기존 `SUCCESS/FAILED` 검색값을 유지하고 선택지·목록 표시명을 공통코드의 `보정성공/보정실패`로 표시한다.
|
||||
- 복구상태는 백엔드가 외부 원천의 한글 설명값을 검색·응답하므로 `RESTORED/RESTORING/NOT_RESTORED`를 API에 전송하지 않는다. CMS의 코드명을 기존 한글 검색값으로 사용해 계약을 유지한다.
|
||||
- 목록은 원본 상태 필드를 유지한 별도 표시 필드를 사용하고, Excel과 상세 패널에도 같은 CMS 코드명을 적용한다.
|
||||
- CMS에 없는 기존 좌표상태 `NO_ADDRESS`는 `주소없음` fallback을 유지한다.
|
||||
- CMS 조회 실패 시 기존 선택지와 상태 문구를 유지한다.
|
||||
- 백엔드 API, 외부 원천 데이터와 DB는 변경하지 않았다.
|
||||
|
||||
@@ -333,3 +333,32 @@
|
||||
- 수정 화면은 기존과 같이 관로정보·준설일자·사진·위치·자동매핑 정보를 모두 제공하므로 조회에 필요한 정보 확인과 편집 기능은 유지된다.
|
||||
- 수정 화면에서도 사용하는 검색 파라미터 복원 로직은 중립 유틸 파일로 분리했으며 API, 저장 payload, 부모창 갱신 메시지는 변경하지 않았다.
|
||||
- 요구사항정의서 sheet3의 `SFR-010-013`은 준설 등록·수정·삭제·조회를 요구하지만 별도 상세 버튼을 강제하지 않는다. 화면설계서 슬라이드 51의 `SN_MN040201`과 슬라이드 53의 `SN_MN040202`도 등록·조회·다운로드 중심이며, 슬라이드 57의 별도 세부현황은 `화면 삭제`로 표시되어 있다.
|
||||
|
||||
## 20. 2026-08-28 일반 HTTP 준설 팝업 UUID 호환성 보완
|
||||
|
||||
- 원격 `http://con.mind-one.co.kr:11302/popup/dredgingRegister`에서 `crypto.randomUUID is not a function` 예외로 등록 팝업이 렌더링 전에 중단됐다.
|
||||
- `localhost`는 브라우저가 보안 컨텍스트 예외로 취급하지만 일반 HTTP 주소에서는 `crypto.randomUUID()` 또는 `crypto` 자체가 제공되지 않을 수 있다.
|
||||
- `lib/uuid.ts`에 준설 전용 UUID v4 생성 함수를 추가했다.
|
||||
- `crypto.randomUUID`가 있으면 기존 동작을 유지한다.
|
||||
- 없으면 `crypto.getRandomValues`를 사용하고 UUID version·variant 비트를 적용한다.
|
||||
- Web Crypto 자체가 없으면 36자 UUID v4 형식을 유지하는 최종 fallback을 사용한다.
|
||||
- 등록 건 ID, 신규 사진 key, 수정 사진 key, 백엔드 `clientRequestId`, mock 작업번호의 직접 `randomUUID` 호출을 모두 공통 함수로 교체했다.
|
||||
- 백엔드 UUID 계약, 등록 payload, DB 구조는 변경하지 않았다.
|
||||
- 추적 근거: 요구사항정의서 `SFR-010-012` G84, 화면설계서 슬라이드 53~59와 화면 ID `SN_MN040201~040203`.
|
||||
|
||||
## 21. 2026-08-28 촬영일시 날짜 표출 통일
|
||||
|
||||
- 등록 화면 사진 표와 수정 화면의 기존·신규 사진 목록에서 촬영일시를 `YYYY-MM-DD`까지만 표시한다.
|
||||
- `lib/date.ts`에 촬영일시 표출 전용 함수를 추가해 ISO `T` 구분, 공백 구분과 날짜 단독 문자열을 같은 형식으로 처리한다.
|
||||
- 존재하지 않는 날짜나 빈 값은 기존 화면별 fallback(`-`, `촬영일시 없음`)을 유지한다.
|
||||
- EXIF 원본 `capturedAt`, 첨부정보와 백엔드 저장 payload는 변경하지 않고 화면 렌더링만 변경했다.
|
||||
- 추적 근거는 요구사항 `SFR-010-012`, 화면설계서 슬라이드 53의 `SN_MN040201`이며 세부 시간 숨김은 사용자 지시에 따른 표출 보완이다.
|
||||
|
||||
## 22. 2026-09-01 준설 공통코드 표시명 적용
|
||||
|
||||
- `DRDGNREGMODE`, `DRDGNLOCSRC`, `DRDGNPHOTOSTTS`를 기존 CMS 캐시 API로 조회하고 브라우저 세션에서 그룹별 응답과 진행 중 요청을 재사용한다.
|
||||
- 등록·수정 화면의 `PHOTO/MANUAL` 버튼명, 위치출처 표시와 등록 화면 사진 메타상태명을 CMS의 `lwpoCdNm`으로 표시한다.
|
||||
- 등록방식, 위치출처, 사진상태 코드값과 저장·검증 분기는 변경하지 않았다.
|
||||
- CMS에 없는 기존 `UNSUPPORTED` 상태는 `수동 입력 필요` fallback을 유지한다.
|
||||
- CMS 조회 실패 시 기존 화면 문구를 fallback으로 사용한다.
|
||||
- 백엔드 API, 준설 저장 payload와 DB 구조는 변경하지 않았다.
|
||||
|
||||
@@ -46,3 +46,42 @@
|
||||
- 엑셀 다운로드는 화면에 마지막으로 적용된 필터·정렬과 같은 조건으로 전체 상세 데이터를 다시 조회한다.
|
||||
- `관로현황`은 페이지당 10건으로 조정하고, 팝업 본문은 필터 영역과 테이블 영역을 분리해 페이지네이션이 현재 창 높이 안에서 바로 보이도록 정리했다.
|
||||
- 이번 수정으로 `관로현황` 팝업의 기존 페이징 기능을 유지하면서, 사용자가 필터 입력만 바꿔 둔 상태에서 페이지 이동이나 정렬을 눌러도 미적용 초안 값이 섞이지 않고 페이지네이션도 다시 노출되도록 정리했다.
|
||||
|
||||
## 8. 2026-08-26 대구광역시 서구 처리구역 선택 보완
|
||||
|
||||
- 처리구역 WFS 원천의 시군구코드가 기준코드와 다를 수 있으므로, 시군구 선택 시 시도 범위의 처리구역을 조회한 뒤 `기준 시군구코드 또는 시군구명`이 일치하는 행만 남긴다.
|
||||
- 선택된 정상 시군구코드를 처리구역 옵션에도 유지해 이후 화면/API 조건이 잘못된 원천코드로 되돌아가지 않게 했다.
|
||||
- 대구광역시 서구 선택 시 `달서천처리구역`, `북부처리구역` 옵션이 노출되는 것을 확인했다.
|
||||
|
||||
## 9. 2026-08-26 관로현황 용도·관재질 검색조건 전체 코드 보완
|
||||
|
||||
- 용도 선택지는 화면에 하드코딩된 합류관·차집관·우수관·오수관 4개 대신 공통 `MS_SWAGE_PPL_USE_CODES` 전체를 사용한다.
|
||||
- 이에 따라 `미분류(SBA000)`와 `기타(SBA999)`가 검색조건에 추가됐다.
|
||||
- 관재질 선택지는 현재 페이지의 최대 10개 결과에서 추출하던 방식을 제거하고 공통 `MS_SWAGE_PPL_CODES` 전체를 사용한다.
|
||||
- 상위 집계에서 전달된 관재질 코드가 공통 목록에 없는 경우에는 기존 fallback 옵션 추가를 유지한다.
|
||||
|
||||
## 10. 2026-08-26 관로현황 선택 시 최상위 지도창 포커스 보완
|
||||
|
||||
- 기존 상세창은 `window.opener?.opener`인 최상위 지도창에 관로 선택 메시지만 전달했다.
|
||||
- 관로행 클릭 시 최상위 지도창에 고정 window name을 부여하고 같은 이름의 기존 창을 다시 선택해 `focus()`를 요청한다.
|
||||
- 상세창과 중간 관로분석창을 `blur()`한 뒤 지도창을 활성화해 중간 팝업에 포커스가 머무는 경우를 방지한다.
|
||||
- 최상위 지도창도 메시지 처리 시작과 지도 이동·상세 패널 렌더링 완료 시점에 각각 `window.focus()`를 요청한다.
|
||||
- 기존 관로 지도 이동, 강조와 우측 상세정보 표출은 그대로 유지한다.
|
||||
|
||||
## 11. 2026-08-26 관로현황 선택 관로 유하방향 근거 보강
|
||||
|
||||
- 관로현황 상세 API 행에는 `flowDirectionBasis`가 없어 지도 상세 패널을 행 데이터로 갱신하면 유하방향 근거가 `-`로 표출됐다.
|
||||
- 관로행 클릭 시 시설관리번호와 관리기관코드로 `v1/fclts` 한 건을 `includeFlowDirectionBasis=true` 조건으로 조회한다.
|
||||
- 조회 결과를 기존 관로현황 행에 병합한 뒤 지도 상세 패널에 전달한다.
|
||||
- 보강 API가 실패하면 지도 이동과 기존 행 상세는 유지하고 경고 로그만 남긴다.
|
||||
- 하수관로 유하방향 레이어 계산과 DB 구조는 변경하지 않는다.
|
||||
|
||||
## 12. 2026-08-28 하수관로 상세 공통 유하방향 근거 보강
|
||||
|
||||
- 사용자 재확인에서 관로분석 행 선택 외의 지도 피처 클릭 경로는 여전히 `유하방향 근거`가 `-`로 표시되는 것을 확인했다.
|
||||
- 관로분석 메시지 처리 함수에 두었던 한정 보강을 제거하고 `FeatureClickEventPopup`의 하수관로 상세 공통 로딩 흐름으로 이동했다.
|
||||
- 지도 피처 클릭, 정보검색/관로현황 연계 등 진입 경로와 관계없이 시설관리번호·관리기관코드가 유효하면 `v1/fclts?includeFlowDirectionBasis=true` 한 건을 조회한다.
|
||||
- 기존 피처 정보와 자체입력정보를 먼저 병합한 뒤 유하방향 응답을 최종 병합해 기존 상세 필드를 잃지 않도록 했다.
|
||||
- 동일 관로의 상세 효과가 개발 모드에서 반복 실행돼도 시설관리번호·관리기관코드별 요청 Promise를 재사용해 중복 API 호출을 막는다. 실패한 요청은 캐시에서 제거해 재시도를 허용한다.
|
||||
- 대상 관로 `2015303665`의 화면 표출값은 `시종점 관저고`이며, 계산코드는 `DEPTH`이다.
|
||||
- 수정 파일은 `app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx`이며, `map/page.tsx`는 관로 선택 행을 그대로 상세 컴포넌트에 전달한다.
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
- 분석기준시간은 DB 결과와 독립된 현재 정시이므로 진행중 데이터가 없어도 사라지지 않는다. 입력창으로 오해하지 않도록 테두리·배경 없이 글씨만 표시한다.
|
||||
- `분석기준시간` 라벨과 시각은 현재 탭 행의 오른쪽 끝으로 이동하고 검색패널에는 중점관리지역 선택과 우측 조회 버튼만 유지한다.
|
||||
- 행 선택 성공 후 왼쪽 아래 안내문은 제거하고 오류 메시지만 조건부 표시한다.
|
||||
- 지도 요약카드에는 capacity 0.8 초과 관로 존재 여부가 위험/정상을 결정한다는 설명을 추가했다.
|
||||
- 지도 요약카드에는 GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면 위험이라는 기준만 간결하게 표시한다.
|
||||
- 결과표 `No.`를 `No`로 바꾸고 표 전체를 `1320px` 고정폭으로 지정한 뒤 `table-fixed`·`colgroup` 비율을 적용해 1·2페이지의 절대 열 너비를 동일하게 고정했다.
|
||||
- 공통 그리드 교체 후 검색영역과 결과영역의 가로·세로 overflow를 숨기고 결과영역 좌우 여백을 보정해 팝업 내부 스크롤바가 나타나지 않게 했다.
|
||||
|
||||
@@ -245,3 +245,18 @@
|
||||
- 중점관리지역 아래 capacity 설명 자체에는 구분선을 두지 않고 문장 안의 `위험`은 빨간색, `정상`은 파란색 텍스트로 강조한다. 설명과 수치표의 경계인 표 상단선은 유지한다.
|
||||
- 기존 위험상태 배지와 카드 `290px` 폭을 유지한다.
|
||||
- 팝업은 `pagehide`·`beforeunload`에서 `WATER_RISK_CLOSED` 메시지를 부모로 전달한다. 부모는 메시지 수신 즉시 중점관리지역·관로·맨홀 레이어, capacity/Flooding 범례, 정보카드와 선택상태를 모두 초기화하며 300ms close watcher는 예비 감시로 유지한다.
|
||||
|
||||
## 17. 2026-08-26 과거분석이력 최신순 기본 정렬·행 키 보완
|
||||
|
||||
- 팝업 최초 조회와 현재·과거 탭 전환 시 정렬 파라미터를 비워 백엔드 기본 강우시작시간 최신순을 적용한다.
|
||||
- 사용자가 다른 정렬 열을 선택하면 기존 서버 정렬을 유지하고, 탭을 전환하면 다시 강우시작시간 최신순으로 돌아간다.
|
||||
- 동일 날짜·지역에 여러 강우이벤트가 존재해도 React 행 키가 겹치지 않도록 분석일·강우시작시간·분석기준시간·행정코드·지역번호를 조합한다.
|
||||
|
||||
## 18. 2026-08-31 통수위험 행 선택과 초단기예보격자망 연계
|
||||
|
||||
- 부모 지도에서 `WATER_RISK_SELECT_RESULT` 메시지를 정상 검증한 뒤 통수위험 결과·mapView 상태와 함께 초단기예보격자망 체크 요청을 생성한다.
|
||||
- 공통 `Gis`에 `checkedLayerRequest`를 추가하고 `GisTree`가 레이어명을 기준으로 기존 체크 상태에 대상 노드를 합치는 방식으로 구현했다.
|
||||
- 대상 노드와 조상인 `강우관련` 그룹을 펼쳐 자동 체크 결과를 사용자가 트리에서 바로 확인할 수 있게 했다.
|
||||
- 요청마다 `requestKey`를 증가시켜 사용자가 격자망을 수동 해제한 후 다른 통수위험 행을 선택해도 다시 활성화한다.
|
||||
- 지도 체크박스 DOM을 직접 조작하지 않고 기존 `checkSpecificNode` 경로를 사용해 실제 WFS 레이어와 체크 상태, `onCheckedLayersChange` 콜백을 함께 갱신한다.
|
||||
- 공통 기능은 요청이 없는 기존 `Gis` 사용 화면에서 동작하지 않으므로 타 화면의 기본 체크 계약은 유지된다.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# SFR-011-008 침수예측지도 격자 표시 FE 구현기록
|
||||
|
||||
## 구현 내용
|
||||
|
||||
- 침수예측현황 선택행의 `analysisDateTime`을 지도 이동 메시지에 포함했다.
|
||||
- BE `grid-map` 응답의 `latcCd`를 GeoServer `tb_ms_shtm_frcst_latc_info` WFS CQL 필터로 사용한다.
|
||||
- 격자 폴리곤 내부점에 `latc_cd`, 침수위험도, 현재·예측 누적강우 라벨을 표시한다.
|
||||
- `SN_FLDNG_PREDICTION_GRID_LABEL` 전용 레이어로 재선택 시 오버레이를 교체한다.
|
||||
|
||||
## 설계 변경 대기
|
||||
|
||||
사용자 확인에 따라 최종 구현 대상은 격자별 라벨이 아니라 선택한 도시침수지도 중앙의 단일 오버레이로 변경되었다. 기존 격자별 구현은 중앙 오버레이 구현으로 교체한다.
|
||||
|
||||
## 기준
|
||||
|
||||
- `침수예측.pptx` 4페이지
|
||||
- 요구사항 `SFR-011-008`
|
||||
@@ -145,3 +145,38 @@
|
||||
- 두 파일 모두 16,670건, EPSG:5179, SQLite 무결성 `ok`
|
||||
- `fid INTEGER PRIMARY KEY` 단일 컬럼과 공간 인덱스 확인
|
||||
- 최종 브라우저 콘솔 오류 및 경고 없음
|
||||
|
||||
## 8. 2026-08-28 GIS 관리 관리기관 목록 초기화 보완
|
||||
|
||||
### 8.1 추적 근거
|
||||
|
||||
- 화면설계서 슬라이드 35 `SN_MN010302`, 요구사항 `SFR-012-002`의 GIS 관리 팝업·시설정보 선택·업로드 흐름을 기준으로 확인했다.
|
||||
- 요구사항정의서 `3. (기능)요구사항정의 및 추적표`의 `SFR-012-002`를 대조했다.
|
||||
|
||||
### 8.2 원인
|
||||
|
||||
- 팝업 초기화가 시설유형 API와 관리기관 API를 하나의 `Promise.all` 및 단일 `try/catch`로 처리했다.
|
||||
- 어느 한 API라도 일시 실패하면 관리기관 API가 정상이어도 `setMngInstOptions`가 실행되지 않아 콤보박스에는 `선택`만 남았다.
|
||||
|
||||
### 8.3 변경 내용
|
||||
|
||||
- 시설유형과 관리기관 조회를 독립 비동기 함수로 분리했다.
|
||||
- 시설유형 조회 실패 시 기본 하수관로 설정을 유지하고, 관리기관 조회 결과는 별도로 정렬·반영한다.
|
||||
- 관리기관 조회 실패와 정상 빈 결과를 구분한 사용자 안내를 추가했다.
|
||||
- BE API·DB·업로드/다운로드 계약은 변경하지 않았다.
|
||||
|
||||
### 8.4 GIS 업로드 목록 이동 버튼 문구
|
||||
|
||||
- 업로드 이력을 다시 조회하고 목록 화면으로 전환하는 버튼명을 `조회`에서 `목록`으로 변경했다.
|
||||
- 기본 업로드 이력 화면과 오류·미리보기 화면의 동일 액션 문구를 함께 통일했다.
|
||||
- 버튼 이벤트와 API 요청은 변경하지 않았다.
|
||||
|
||||
### 8.5 2026-09-01 GIS 관리 공통코드 1차 적용
|
||||
|
||||
- `GISULDSTTS`, `GISDWNLDSTTS`, `SNFCLTSE`를 기존 CMS 캐시 URL로 기능 진입 시 병렬 조회한다.
|
||||
- 같은 브라우저 세션에서는 그룹별 조회 결과와 진행 중 요청을 재사용해 팝업 재진입 시 중복 호출을 줄였다.
|
||||
- 업로드의 `COMPLETED/VALIDATION_FAILED/FAILED`와 다운로드의 `QUEUED/RUNNING/READY/FAILED` 분기는 그대로 유지하고 안내문 fallback에 공통코드명을 사용한다.
|
||||
- 다운로드 대기 중에는 현재 작업상태를 공통코드명으로 갱신한다.
|
||||
- 시설구분은 백엔드 지원범위 `PPL` 단일을 유지하면서 화면 표시명만 `SNFCLTSE`의 `PPL=관로`로 표시한다. `MNHL` 기능은 새로 활성화하지 않았다.
|
||||
- CMS 조회 실패 시 기존 업로드·다운로드·하수관로 문구를 fallback으로 사용한다.
|
||||
- 백엔드 API, 업로드·다운로드 payload와 DB 계약은 변경하지 않았다.
|
||||
|
||||
@@ -488,3 +488,13 @@
|
||||
- 편집 재진입 시 `gisEditSessionView`를 `inline`으로 복원한다.
|
||||
- 팝업 sync effect도 편집 모드가 꺼져 있으면 실행하지 않는다.
|
||||
- `useGisEdit`의 변경분 배열은 유지하므로 다시 진입하면 기존 변경 카드가 복원된다.
|
||||
|
||||
### 4.32 2026-09-01 GIS 편집작업 공통코드 1차 적용
|
||||
|
||||
- 기존 CMS 호출 구조인 `getCmsData('/api/v1/code/cache/{upCd}')`를 유지한 `getCommonCodes(upCd)` 타입 안전 조회 함수를 추가했다.
|
||||
- 기존 `searchCmmnCd()`도 같은 조회 함수를 사용하도록 정리해 CMS의 배열 응답을 `getData()`의 공통 응답 wrapper로 잘못 해제하던 경로를 제거했다.
|
||||
- `useGisEditOperationLabels()`는 `GISEDITOPRT`를 브라우저 세션당 한 번 조회하고 툴바와 인라인·윈도우 편집 세션이 같은 코드명을 공유한다.
|
||||
- `ADD`, `MOVE`, `VERTEX_EDIT`, `DELETE` 타입과 상태 분기는 유지하고 표시명만 공통코드의 `lwpoCdNm`으로 교체했다.
|
||||
- CMS 조회 실패 시에는 기존 `추가/이동/꼭지점편집/삭제` 문구를 fallback으로 사용해 편집 기능 자체가 중단되지 않게 했다.
|
||||
- `GISEDITTYPE(C/U/D)`는 실제 CMS 응답까지 확인했지만 현재 SN GIS 편집 화면에 직접 표시되는 소비처가 없어 불필요한 조회는 추가하지 않았다.
|
||||
- 백엔드 API, 저장 payload, DB 구조는 변경하지 않았다.
|
||||
|
||||
@@ -139,3 +139,12 @@
|
||||
- 공통 팝업의 12px 간격과 중복되던 별도 여백을 제거하고, WindowPopup 크기를 `1400×720`으로 조정했다. 빈 목록은 목록 카드가 남은 세로 공간을 채우도록 해 카드 밖에 과도한 공백이 남지 않게 했다.
|
||||
- `StandardDataTable`에 팝업 전용 바깥 margin 제거 옵션을 추가해 이 팝업의 검색 카드와 목록 카드 좌우 기준선을 동일하게 맞췄다. 일반 페이지의 기존 카드 margin은 그대로 보존한다. 빈 목록은 남은 높이를 채우고, 조회 결과가 있으면 표의 자연 높이를 보존해 팝업 본문만 세로 이동하도록 공통 동작을 구분했다.
|
||||
- API, 조회조건, 다중선택, Excel 다운로드, 등록과 부모창 메시지 계약은 변경하지 않았다.
|
||||
|
||||
## 2026-09-01 운영사업 공통코드 화면 적용
|
||||
|
||||
- `OBSLTBIZSE`, `EVDNCSBMSNSTTS`, `BIZPRGRSSTTS`를 기존 CMS 캐시 API로 조회하고 브라우저 세션에서 재사용한다.
|
||||
- 사업구분 Select는 CMS 코드명을 기존 한글 전송값으로 사용해 `정밀조사/정비사업` DB·API 계약을 유지한다.
|
||||
- 추진현황과 증빙서류 상태는 기존 `준공`, `발주중`, `O/X`, `공단` 등의 자유문자를 보존하면서 CMS 표준값을 datalist 선택 후보로 제공한다.
|
||||
- 목록은 CMS 코드값 또는 코드명과 정확히 일치하는 값만 코드명으로 표시하고 미등록 기존 값은 그대로 표시한다.
|
||||
- 영문 `lwpoCd` 저장 전환과 기존 데이터 이관은 이번 범위에서 수행하지 않았다.
|
||||
- 백엔드 API, Excel 입출력과 DB는 변경하지 않았다.
|
||||
|
||||
@@ -14,3 +14,11 @@
|
||||
|
||||
- 신규 FE feature와 route만 추가했다.
|
||||
- 기존 공통 store와 UI 컴포넌트는 소비만 하며 수정하지 않았다.
|
||||
|
||||
## 2026-09-01 운영사업 공통코드 화면 적용
|
||||
|
||||
- `CTYFLDNGPRGRSSE`, `CTYFLDNGDSGNTYPE`, `BIZPRGRSSTTS`를 기존 CMS 캐시 API로 조회한다.
|
||||
- `EM/MP/DD/CW` 상세 구역 제목을 CMS 코드명으로 표시하되 상세 코드와 Excel 열 순서는 유지한다.
|
||||
- 지정유형과 두 추진현황 입력은 자유문자 기존값을 유지하면서 CMS 표준값을 datalist 후보로 제공한다.
|
||||
- 목록은 CMS와 정확히 일치하는 지정유형·추진현황만 코드명으로 표시하고 `완료(20.06.)` 같은 기존 상세값은 그대로 표시한다.
|
||||
- 영문 코드 저장과 기존 데이터 이관은 수행하지 않았으며 백엔드 API·Excel·DB는 변경하지 않았다.
|
||||
|
||||
@@ -129,3 +129,33 @@
|
||||
- 정보검색 기본정보의 하수관로 목록만 `includeFlowDirectionBasis=true`를 전달해 현재 페이지의 유하방향 근거를 배치 보강한다.
|
||||
- 노후관로 목록은 해당 파라미터를 전달하지 않아 페이징·CQL 필터·WFS 서버 정렬과 기존 시설 정보 병합만 수행한다.
|
||||
- 정렬 정확도를 위해 전체 필터 결과의 서버 정렬은 유지하며, 현재 페이지 배열만 정렬하는 임시 우회는 사용하지 않는다.
|
||||
|
||||
## 16. 2026-08-26 유지관리 대메뉴 적용
|
||||
|
||||
- 사용자 최신 지시에 따라 FO·WR는 적용하지 않고 SN `유지관리` 대메뉴만 구현했다.
|
||||
- 공통 `StandardTableSortHeader`, `sortStandardTableRows`를 추가해 StandardDataTable 계열이 기존 UiTable과 같은 3상태 정렬을 선택 사용하도록 했다.
|
||||
- 미선택 아이콘은 흰색, 선택 아이콘은 노란색이며 헤더 높이를 유지한 채 아이콘을 한 단계 확대했다.
|
||||
- 조사 목록은 `page/size=10` 서버 페이징과 기존 `ExmnBusinessRepositoryImpl` 정렬을 연결했다.
|
||||
- 조사 상세현황과 관로·맨홀 상세 팝업은 전체 결과 로컬 정렬을 적용하고 합계·소계·가변 결함·병합 본단위표는 제외했다.
|
||||
- 준설 지자체별·관경별·경과년도별은 전체 페이지 수집 결과를 정렬한 뒤 10건 페이지를 자르고 Excel에도 정렬 순서를 유지한다.
|
||||
- 처리분구관리는 전체 활성 목록을 정렬한 뒤 페이지를 자르며 추가·수정 시작 시 기본순서로 복귀한다.
|
||||
- 상세 화면·정렬 컬럼·제외 사유는 `COMMON_DOCS_ROOT/작업내용/공통-UiTable-컬럼정렬/003-ai-공통-UiTable-컬럼정렬-20260826-유지관리대메뉴-구현기록.md`에 통합했다.
|
||||
|
||||
## 17. 2026-08-26 운영정보·I/I 분석 적용
|
||||
|
||||
- 도시침수예방사업 직접 필드 23개와 노후 하수관로 정비사업 22개를 기존 서버 Pageable 정렬에 연결했다.
|
||||
- 두 운영정보 Excel 전체 페이지 수집은 현재 화면 sort를 유지한다.
|
||||
- 결산연도·단계별 자식 상세와 등록·수정 입력표는 제외했다.
|
||||
- 침입수 결과 7개, RDII 결과 9개 원천 필드를 서버 정렬에 연결했다.
|
||||
- I/I 코드명·외부 사용자명 컬럼은 잘못된 코드·UUID 정렬 대신 비활성화했다.
|
||||
- `MapPopupDataTable`의 정렬 헤더도 공통 Standard 스타일로 통일했다.
|
||||
- 상세 결과는 `COMMON_DOCS_ROOT/작업내용/공통-UiTable-컬럼정렬/004-ai-공통-UiTable-컬럼정렬-20260826-운영정보-I-I분석-구현기록.md`를 따른다.
|
||||
|
||||
## 18. 2026-08-26 운영정보 Excel 생성 보완
|
||||
|
||||
- 사용자 첨부 생성본 2개를 `COMMON_DOCS_ROOT/작업지시`의 확정 원본 양식 2개와 직접 비교했다.
|
||||
- 도시침수 Excel은 제목·요약·본문 서식을 복원하고 순번을 출력 순서 `1..N`으로 변경했으며 결산 총계를 연도별 합으로 채운다.
|
||||
- 노후관로 Excel은 두 시트의 제목·조회 건수·계획연장 합계, B6:V7 병합 헤더·구분색과 고정 본문 높이를 적용했다.
|
||||
- 두 생성기 모두 API 문자열의 `_x000d_`를 실제 줄바꿈으로 정리한다.
|
||||
- 노후관로 다운로드는 비동기 스타일 생성 완료를 `await`한 뒤 로딩 상태를 해제한다.
|
||||
- 시트명·업로드 열 위치, 조회조건·정렬순서, 백엔드·DB 계약은 변경하지 않았다.
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PromptProvider, StoreProvider } from "@/shared/providers";
|
||||
import { AlertDialog } from "@/shared/ui/alert-dialog";
|
||||
import { PromptDialog } from "@/shared/ui/prompt-dialog";
|
||||
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
|
||||
import { BusinessUserMessageBridge } from "@/shared/ui/business-user-message-bridge";
|
||||
|
||||
// 개발 도구는 실제 로컬 개발 환경에서만 별도 청크로 불러와 최초 페이지 컴파일 범위를 줄인다.
|
||||
const LazyReactQueryDevtools = dynamic(
|
||||
@@ -26,6 +27,7 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<StoreProvider>
|
||||
<BusinessUserMessageBridge />
|
||||
<PromptProvider>
|
||||
<ConfirmProvider>
|
||||
<AuthWrapper>
|
||||
|
||||
@@ -46,6 +46,7 @@ import type {
|
||||
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
|
||||
type SelectOption = { text: string; val: string };
|
||||
type CfmtnBgtTableRow = CfmtnBgtRow;
|
||||
@@ -159,6 +160,8 @@ export default function CfmtnBgtPopupContent({
|
||||
});
|
||||
|
||||
const [search, setSearch] = useState(initial);
|
||||
// 화면 목록을 마지막으로 조회한 조건이며 페이지 이동·Excel이 동일하게 사용한다.
|
||||
const [appliedSearch, setAppliedSearch] = useState(initial);
|
||||
const [codes, setCodes] = useState<CfmtnBgtCode[]>([]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [data, setData] = useState(empty);
|
||||
@@ -414,6 +417,7 @@ export default function CfmtnBgtPopupContent({
|
||||
: empty,
|
||||
);
|
||||
setPage(targetPage);
|
||||
setAppliedSearch({ ...targetSearch });
|
||||
setNotice("");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
@@ -449,26 +453,21 @@ export default function CfmtnBgtPopupContent({
|
||||
};
|
||||
|
||||
const download = async () => {
|
||||
if (JSON.stringify(search) !== JSON.stringify(appliedSearch)) {
|
||||
setNotice("조회조건이 변경되었습니다. 조회 후 Excel을 다운로드해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
let exportPage = 0;
|
||||
let totalPages = 1;
|
||||
const exportRows: CfmtnBgtRow[] = [];
|
||||
while (exportPage < totalPages) {
|
||||
const result = await getCfmtnBgtPage(params(exportPage, EXPORT_SIZE));
|
||||
const safeResult =
|
||||
result &&
|
||||
typeof result === "object" &&
|
||||
"content" in result &&
|
||||
Array.isArray((result as CfmtnBgtPage).content) &&
|
||||
"totalPages" in result
|
||||
? (result as CfmtnBgtPage)
|
||||
: empty;
|
||||
exportRows.push(...safeResult.content);
|
||||
totalPages = safeResult.totalPages;
|
||||
exportPage += 1;
|
||||
}
|
||||
downloadCfmtnBgtExcel(exportRows, search.year);
|
||||
const result = await fetchAllPages<CfmtnBgtPage, CfmtnBgtRow>({
|
||||
batchSize: EXPORT_SIZE,
|
||||
fetchPage: (targetPage, size) =>
|
||||
getCfmtnBgtPage(params(targetPage, size, appliedSearch)),
|
||||
getItems: (page) => (Array.isArray(page?.content) ? page.content : []),
|
||||
getTotal: (page) => Number(page?.totalElements ?? 0),
|
||||
});
|
||||
downloadCfmtnBgtExcel(result.items, appliedSearch.year);
|
||||
setNotice("");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
@@ -522,7 +521,7 @@ export default function CfmtnBgtPopupContent({
|
||||
const next =
|
||||
typeof updater === "function" ? updater(pagination) : updater;
|
||||
if (next.pageIndex === page) return;
|
||||
void load(next.pageIndex);
|
||||
void load(next.pageIndex, appliedSearch);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
postRawJson,
|
||||
putData,
|
||||
} from "@/components/lib/api";
|
||||
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
|
||||
const API_PATH = "v1/ctyFldngPrvtnBiz";
|
||||
|
||||
@@ -27,21 +28,21 @@ export const getCtyFldngPrvtnBusinesses = (params: URLSearchParams) =>
|
||||
/** 모든 페이지를 순회하여 Excel 대상 전체를 모은다. */
|
||||
export const getAllCtyFldngPrvtnBusinesses = async (
|
||||
search: URLSearchParams,
|
||||
sort = "ctyFldngPrvtnBizSn,desc",
|
||||
) => {
|
||||
const rows: CtyFldngPrvtnBiz[] = [];
|
||||
let page = 0;
|
||||
let totalPages = 1;
|
||||
while (page < totalPages) {
|
||||
const params = new URLSearchParams(search);
|
||||
params.set("page", String(page));
|
||||
params.set("size", "500");
|
||||
params.set("sort", "ctyFldngPrvtnBizSn,asc");
|
||||
const result = await getCtyFldngPrvtnBusinesses(params);
|
||||
rows.push(...result.content);
|
||||
totalPages = result.totalPages;
|
||||
page += 1;
|
||||
}
|
||||
return rows;
|
||||
const result = await fetchAllPages<CtyFldngPrvtnBizPage, CtyFldngPrvtnBiz>({
|
||||
batchSize: 500,
|
||||
fetchPage: (page, size) => {
|
||||
const params = new URLSearchParams(search);
|
||||
params.set("page", String(page));
|
||||
params.set("size", String(size));
|
||||
params.set("sort", sort);
|
||||
return getCtyFldngPrvtnBusinesses(params);
|
||||
},
|
||||
getItems: (page) => page.content ?? [],
|
||||
getTotal: (page) => Number(page.totalElements ?? 0),
|
||||
});
|
||||
return result.items;
|
||||
};
|
||||
|
||||
/** 수정 화면의 사업 한 건을 조회한다. */
|
||||
|
||||
@@ -41,6 +41,17 @@ const textValue = (value: unknown): string | null => {
|
||||
return !valueText || valueText === "-" ? null : valueText;
|
||||
};
|
||||
|
||||
/** API 문자열에 남은 Excel 개행 이스케이프를 실제 줄바꿈으로 정리한다. */
|
||||
const excelTextValue = (value: unknown): string | number => {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value !== "string") return String(value);
|
||||
return value
|
||||
.replace(/_x000d_/gi, "")
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.trim();
|
||||
};
|
||||
|
||||
/** 콤마와 단위를 제거한 유한 숫자를 반환한다. */
|
||||
const numberValue = (
|
||||
value: unknown,
|
||||
@@ -333,7 +344,7 @@ const writeDetail = (
|
||||
detail.exlnPplImprvmLen,
|
||||
detail.rnwtrPumpstSumCn,
|
||||
].forEach((v) => {
|
||||
row[start + o++] = v ?? "";
|
||||
row[start + o++] = excelTextValue(v);
|
||||
});
|
||||
for (let i = 0; i < PUMP_COUNTS[code]; i += 1)
|
||||
row[start + o++] =
|
||||
@@ -346,7 +357,7 @@ const writeDetail = (
|
||||
row[start + o++] = detail.impdFclt?.find((v) => v.no === i + 1)?.cpct ?? "";
|
||||
[detail.tnlDmtr, detail.tnlLen, detail.tnlRscpt, detail.tnlExcvDpth].forEach(
|
||||
(v) => {
|
||||
row[start + o++] = v ?? "";
|
||||
row[start + o++] = excelTextValue(v);
|
||||
},
|
||||
);
|
||||
if (code !== "EM") {
|
||||
@@ -363,10 +374,10 @@ const writeDetail = (
|
||||
detail.frstAprvCn,
|
||||
detail.frstChgAprvCn,
|
||||
].forEach((v) => {
|
||||
row[start + o++] = v ?? "";
|
||||
row[start + o++] = excelTextValue(v);
|
||||
});
|
||||
if (code === "MP") row[start + o++] = detail.scndChgAprvCn ?? "";
|
||||
if (code === "CW") row[start + o++] = detail.rmrkSrcCn ?? "";
|
||||
if (code === "MP") row[start + o++] = excelTextValue(detail.scndChgAprvCn);
|
||||
if (code === "CW") row[start + o++] = excelTextValue(detail.rmrkSrcCn);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -557,6 +568,7 @@ const saveStyledWorkbook = async (
|
||||
filename: string,
|
||||
starts: Record<(typeof DETAIL_CODES)[number], number>,
|
||||
last: number,
|
||||
rowCount: number,
|
||||
) => {
|
||||
const data = XLSX.write(workbook, { type: "array", bookType: "xlsx" });
|
||||
const styledWorkbook = await XlsxPopulate.fromDataAsync(data);
|
||||
@@ -571,6 +583,23 @@ const saveStyledWorkbook = async (
|
||||
wrapText: true,
|
||||
border: true,
|
||||
};
|
||||
styledSheet.range("B2:Q3").style({
|
||||
bold: true,
|
||||
fontFamily: "맑은 고딕",
|
||||
fontSize: 16,
|
||||
horizontalAlignment: "center",
|
||||
verticalAlignment: "center",
|
||||
border: true,
|
||||
});
|
||||
styledSheet.range("B5:N5").style({
|
||||
bold: true,
|
||||
fontFamily: "맑은 고딕",
|
||||
fontSize: 10,
|
||||
horizontalAlignment: "center",
|
||||
verticalAlignment: "center",
|
||||
fill: "D9EAF7",
|
||||
border: true,
|
||||
});
|
||||
styledSheet.range(`B8:${lastColumn}12`).style(commonHeaderStyle);
|
||||
styledSheet
|
||||
.range(`B8:${XLSX.utils.encode_col(starts.EM - 1)}12`)
|
||||
@@ -591,6 +620,32 @@ const saveStyledWorkbook = async (
|
||||
[8, 9, 10, 11, 12].forEach((row) =>
|
||||
styledSheet.row(row).height(row === 8 ? 30 : 24),
|
||||
);
|
||||
styledSheet.row(2).height(26);
|
||||
styledSheet.row(3).height(26);
|
||||
styledSheet.row(5).height(22);
|
||||
if (rowCount > 0) {
|
||||
const dataStartRow = DATA_START_ROW + 1;
|
||||
const dataEndRow = DATA_START_ROW + rowCount;
|
||||
styledSheet.range(`B${dataStartRow}:${lastColumn}${dataEndRow}`).style({
|
||||
fontFamily: "맑은 고딕",
|
||||
fontSize: 10,
|
||||
horizontalAlignment: "center",
|
||||
verticalAlignment: "center",
|
||||
wrapText: false,
|
||||
border: true,
|
||||
});
|
||||
styledSheet
|
||||
.range(`R${dataStartRow}:${lastColumn}${dataEndRow}`)
|
||||
.style("numberFormat", "#,##0.###");
|
||||
styledSheet
|
||||
.range(`M${dataStartRow}:Q${dataEndRow}`)
|
||||
.style("horizontalAlignment", "left");
|
||||
styledSheet
|
||||
.range(`${lastColumn}${dataStartRow}:${lastColumn}${dataEndRow}`)
|
||||
.style({ horizontalAlignment: "left", wrapText: true });
|
||||
for (let row = dataStartRow; row <= dataEndRow; row += 1)
|
||||
styledSheet.row(row).height(22);
|
||||
}
|
||||
const blob = await styledWorkbook.outputAsync();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
@@ -625,7 +680,30 @@ export const downloadCtyFldngPrvtnExcel = async (
|
||||
Array(last + 1).fill(""),
|
||||
);
|
||||
const merges: XLSX.Range[] = [];
|
||||
aoa[0]![1] = "하수도정비중점관리지역 도시침수대응사업 현황";
|
||||
aoa[1]![1] = "하수도정비중점관리지역 도시침수 대응사업 추진 현황";
|
||||
addMerge(merges, 1, 1, 2, 16);
|
||||
if (templateOnly) {
|
||||
putHeader(aoa, 4, 1, "업로드 양식 · 13행부터 사업자료를 입력해 주세요.");
|
||||
addMerge(merges, 4, 1, 4, 13);
|
||||
} else {
|
||||
const totalBudget = businesses.reduce(
|
||||
(sum, business) => sum + (business.tpco ?? 0),
|
||||
0,
|
||||
);
|
||||
putHeader(aoa, 4, 1, "조회 건수");
|
||||
addMerge(merges, 4, 1, 4, 3);
|
||||
putHeader(aoa, 4, 4, `${businesses.length.toLocaleString("ko-KR")}건`);
|
||||
addMerge(merges, 4, 4, 4, 6);
|
||||
putHeader(aoa, 4, 7, "총사업비 합계(백만원)");
|
||||
addMerge(merges, 4, 7, 4, 9);
|
||||
putHeader(
|
||||
aoa,
|
||||
4,
|
||||
10,
|
||||
totalBudget ? totalBudget.toLocaleString("ko-KR") : "-",
|
||||
);
|
||||
addMerge(merges, 4, 10, 4, 13);
|
||||
}
|
||||
const verticalBasicHeaders: Array<[number, string]> = [
|
||||
[1, "순번"],
|
||||
[2, "단계"],
|
||||
@@ -680,7 +758,7 @@ export const downloadCtyFldngPrvtnExcel = async (
|
||||
businesses.forEach((b, index) => {
|
||||
const row = Array(last + 1).fill("");
|
||||
[
|
||||
b.seqNo,
|
||||
index + 1,
|
||||
b.stp,
|
||||
b.ctpvNm,
|
||||
b.sggNm,
|
||||
@@ -700,11 +778,22 @@ export const downloadCtyFldngPrvtnExcel = async (
|
||||
b.gvexp,
|
||||
b.lcexp,
|
||||
].forEach((v, i) => {
|
||||
row[i + 1] = v ?? "";
|
||||
row[i + 1] = excelTextValue(v);
|
||||
});
|
||||
years.forEach((year, i) => {
|
||||
row[21 + i] =
|
||||
b.settlements?.find((s) => s.soaYr === year)?.soaAmt ?? "";
|
||||
const settlementAmounts = years.map(
|
||||
(year) => b.settlements?.find((s) => s.soaYr === year)?.soaAmt,
|
||||
);
|
||||
const hasSettlement = settlementAmounts.some(
|
||||
(amount) => amount !== null && amount !== undefined,
|
||||
);
|
||||
row[20] = hasSettlement
|
||||
? settlementAmounts.reduce<number>(
|
||||
(sum, amount) => sum + (amount ?? 0),
|
||||
0,
|
||||
)
|
||||
: "";
|
||||
settlementAmounts.forEach((amount, i) => {
|
||||
row[21 + i] = amount ?? "";
|
||||
});
|
||||
DETAIL_CODES.forEach((code) =>
|
||||
writeDetail(
|
||||
@@ -714,14 +803,24 @@ export const downloadCtyFldngPrvtnExcel = async (
|
||||
code,
|
||||
),
|
||||
);
|
||||
row[last - 2] = b.picNm ?? "";
|
||||
row[last - 1] = b.picCnpl ?? "";
|
||||
row[last] = b.excptnMttrCn ?? "";
|
||||
row[last - 2] = excelTextValue(b.picNm);
|
||||
row[last - 1] = excelTextValue(b.picCnpl);
|
||||
row[last] = excelTextValue(b.excptnMttrCn);
|
||||
aoa.push(row);
|
||||
});
|
||||
const sheet = XLSX.utils.aoa_to_sheet(aoa);
|
||||
sheet["!merges"] = merges;
|
||||
sheet["!cols"] = Array.from({ length: last + 1 }, () => ({ wch: 14 }));
|
||||
const columnWidths = Array.from({ length: last + 1 }, () => ({ wch: 11 }));
|
||||
const widthOverrides: Array<[number, number]> = [
|
||||
[0, 3], [1, 7], [2, 9], [3, 12], [4, 12], [5, 12], [6, 11],
|
||||
[7, 12], [8, 10], [9, 9], [10, 13], [11, 13], [12, 32], [13, 30],
|
||||
[14, 16], [15, 20], [16, 15], [17, 14], [18, 14], [19, 14],
|
||||
[last - 2, 12], [last - 1, 16], [last, 42],
|
||||
];
|
||||
widthOverrides.forEach(([column, width]) => {
|
||||
columnWidths[column] = { wch: width };
|
||||
});
|
||||
sheet["!cols"] = columnWidths;
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, SHEET_NAME);
|
||||
await saveStyledWorkbook(
|
||||
@@ -729,5 +828,6 @@ export const downloadCtyFldngPrvtnExcel = async (
|
||||
templateOnly ? "도시침수예방사업_업로드양식.xlsx" : DOWNLOAD_FILENAME,
|
||||
starts,
|
||||
last,
|
||||
templateOnly ? 0 : businesses.length,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { useOperBusinessCommonCodes } from "@/shared/lib/use-oper-business-common-codes";
|
||||
import useStore from "@/store/useStore";
|
||||
|
||||
const DETAIL_META = [
|
||||
@@ -281,6 +282,12 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
|
||||
const searchParams = useSearchParams();
|
||||
const { confirm } = useConfirm();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
// 자유문자 기존값은 유지하고 지정유형·추진현황 후보와 단계명을 CMS에서 제공한다.
|
||||
const {
|
||||
cityDesignationTypeOptions,
|
||||
businessProgressOptions,
|
||||
cityProgressStageLabels,
|
||||
} = useOperBusinessCommonCodes();
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const [form, setForm] = useState<CtyFldngPrvtnBizSaveRequest>(emptyForm);
|
||||
const [loading, setLoading] = useState(Boolean(id));
|
||||
@@ -372,6 +379,14 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
|
||||
type,
|
||||
]: (typeof BASIC_FIELDS)[number]) => {
|
||||
const fieldId = `cty-fldng-basic-${key}`;
|
||||
const suggestionOptions =
|
||||
key === "dsgnType"
|
||||
? cityDesignationTypeOptions
|
||||
: key === "prplPrst" || key === "bizPrplPrst"
|
||||
? businessProgressOptions
|
||||
: [];
|
||||
const suggestionListId =
|
||||
suggestionOptions.length > 0 ? `${fieldId}-suggestions` : undefined;
|
||||
const span: 1 | 2 | 3 =
|
||||
key === "excptnMttrCn" ? 3 : key === "bizNm" || key === "pstn" ? 2 : 1;
|
||||
return (
|
||||
@@ -382,21 +397,33 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
|
||||
required={key === "bizNm"}
|
||||
span={span}
|
||||
>
|
||||
<Input
|
||||
id={fieldId}
|
||||
className="w-full !text-[1.3rem]"
|
||||
type={type === "date" ? "date" : type}
|
||||
value={String(form[key] ?? "")}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
[key]:
|
||||
type === "date"
|
||||
? formatDateInput(event.target.value)
|
||||
: inputValue(event.target.value, type),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<Input
|
||||
id={fieldId}
|
||||
className="w-full !text-[1.3rem]"
|
||||
type={type === "date" ? "date" : type}
|
||||
list={suggestionListId}
|
||||
value={String(form[key] ?? "")}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
[key]:
|
||||
type === "date"
|
||||
? formatDateInput(event.target.value)
|
||||
: inputValue(event.target.value, type),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{suggestionListId ? (
|
||||
<datalist id={suggestionListId}>
|
||||
{suggestionOptions.map((option) => (
|
||||
<option key={option.code} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
) : null}
|
||||
</>
|
||||
</StandardFormField>
|
||||
);
|
||||
};
|
||||
@@ -539,7 +566,7 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
|
||||
return (
|
||||
<StandardFormSection
|
||||
key={meta.code}
|
||||
title={`${meta.code} ${meta.title}`}
|
||||
title={`${meta.code} ${cityProgressStageLabels[meta.code] || meta.title}`}
|
||||
>
|
||||
<DetailEditor
|
||||
detail={detail}
|
||||
|
||||
@@ -5,7 +5,9 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import {
|
||||
StandardDataTable,
|
||||
StandardTableSortHeader,
|
||||
getStandardRowNumber,
|
||||
type UiTableSort,
|
||||
} from "@common/business_components";
|
||||
import {
|
||||
Button,
|
||||
@@ -40,6 +42,7 @@ import type {
|
||||
} from "../model/cty-fldng-prvtn-biz-types";
|
||||
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { useOperBusinessCommonCodes } from "@/shared/lib/use-oper-business-common-codes";
|
||||
import useStore from "@/store/useStore";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
@@ -206,10 +209,24 @@ const createTableColumn = (
|
||||
id: string,
|
||||
title: string,
|
||||
widthPercent = 8,
|
||||
sortKey?: string,
|
||||
sort?: UiTableSort,
|
||||
onSortChange?: (sort: UiTableSort | undefined) => void,
|
||||
): ColumnDef<TableRow> => ({
|
||||
id,
|
||||
accessorFn: (row) => row[id],
|
||||
header: () => <span className="whitespace-pre-line">{title}</span>,
|
||||
header: () =>
|
||||
sortKey && onSortChange ? (
|
||||
<StandardTableSortHeader
|
||||
label={title}
|
||||
title={<span className="whitespace-pre-line">{title}</span>}
|
||||
sortKey={sortKey}
|
||||
sort={sort}
|
||||
onSortChange={onSortChange}
|
||||
/>
|
||||
) : (
|
||||
<span className="whitespace-pre-line">{title}</span>
|
||||
),
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
const displayValue =
|
||||
@@ -260,6 +277,13 @@ const flattenRow = (row: CtyFldngPrvtnBiz, rowNumber: number): TableRow => {
|
||||
export function CtyFldngPrvtnBizListPage() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
// 원본 자유문자 값은 유지하고 지정유형·추진현황·단계 표시명만 CMS 기준으로 변환한다.
|
||||
const {
|
||||
businessProgressOptions,
|
||||
cityProgressStageLabels,
|
||||
cityDesignationTypeLabels,
|
||||
businessProgressLabels,
|
||||
} = useOperBusinessCommonCodes();
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const defaultDesignationDateRange = useMemo(
|
||||
@@ -286,12 +310,28 @@ export function CtyFldngPrvtnBizListPage() {
|
||||
);
|
||||
const [data, setData] = useState(EMPTY_PAGE);
|
||||
const [rows, setRows] = useState<TableRow[]>([]);
|
||||
const displayRows = useMemo(
|
||||
() =>
|
||||
rows.map((row) => ({
|
||||
...row,
|
||||
dsgnType:
|
||||
cityDesignationTypeLabels[String(row.dsgnType ?? "")] ||
|
||||
row.dsgnType,
|
||||
prplPrst:
|
||||
businessProgressLabels[String(row.prplPrst ?? "")] || row.prplPrst,
|
||||
bizPrplPrst:
|
||||
businessProgressLabels[String(row.bizPrplPrst ?? "")] ||
|
||||
row.bizPrplPrst,
|
||||
})),
|
||||
[businessProgressLabels, cityDesignationTypeLabels, rows],
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [ctpvCd, setCtpvCd] = useState(params.get("ctpvCd") ?? "0");
|
||||
const [sggCd, setSggCd] = useState(params.get("sggCd") ?? "0");
|
||||
const [appliedCtpvCd, setAppliedCtpvCd] = useState(ctpvCd);
|
||||
const [appliedSggCd, setAppliedSggCd] = useState(sggCd);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
const availableSgg = useMemo(
|
||||
() =>
|
||||
sgg.filter(
|
||||
@@ -308,10 +348,37 @@ export function CtyFldngPrvtnBizListPage() {
|
||||
),
|
||||
[],
|
||||
);
|
||||
const detailGroups = useMemo(
|
||||
() =>
|
||||
DETAIL_GROUPS.map((group) => ({
|
||||
...group,
|
||||
title: cityProgressStageLabels[group.code] || group.title,
|
||||
})),
|
||||
[cityProgressStageLabels],
|
||||
);
|
||||
/** 정렬 변경 시 서버 첫 페이지로 돌아가고 동일 검색조건을 유지한다. */
|
||||
const handleSortChange = useCallback(
|
||||
(nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setPage(1);
|
||||
const q = listQuery(1, applied, appliedCtpvCd, appliedSggCd);
|
||||
router.replace(`/oper/ctyFldngPrvtnBiz${q ? `?${q}` : ""}`, {
|
||||
scroll: false,
|
||||
});
|
||||
},
|
||||
[applied, appliedCtpvCd, appliedSggCd, router],
|
||||
);
|
||||
const columns = useMemo<ColumnDef<TableRow>[]>(
|
||||
() => [
|
||||
...BASIC_HEADLIST.map((head) =>
|
||||
createTableColumn(head.id, head.title, head.widthPercent),
|
||||
createTableColumn(
|
||||
head.id,
|
||||
head.title,
|
||||
head.widthPercent,
|
||||
head.id === "rowNumber" ? "ctyFldngPrvtnBizSn" : head.id,
|
||||
sort,
|
||||
handleSortChange,
|
||||
),
|
||||
),
|
||||
{
|
||||
id: "settlements",
|
||||
@@ -320,7 +387,7 @@ export function CtyFldngPrvtnBizListPage() {
|
||||
createTableColumn(`soa_${year}`, `${year}년`, 7),
|
||||
),
|
||||
},
|
||||
...DETAIL_GROUPS.map((group) => ({
|
||||
...detailGroups.map((group) => ({
|
||||
id: `detail_${group.code}`,
|
||||
header: group.title,
|
||||
columns: DETAIL_FIELDS.map((field, index) =>
|
||||
@@ -332,17 +399,24 @@ export function CtyFldngPrvtnBizListPage() {
|
||||
),
|
||||
})),
|
||||
...TAIL_HEADLIST.map((head) =>
|
||||
createTableColumn(head.id, head.title, head.widthPercent),
|
||||
createTableColumn(
|
||||
head.id,
|
||||
head.title,
|
||||
head.widthPercent,
|
||||
head.id,
|
||||
sort,
|
||||
handleSortChange,
|
||||
),
|
||||
),
|
||||
],
|
||||
[years],
|
||||
[detailGroups, handleSortChange, sort, years],
|
||||
);
|
||||
const pagination: PaginationState = {
|
||||
pageIndex: page - 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
};
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
data: displayRows,
|
||||
columns,
|
||||
state: { pagination },
|
||||
rowCount: data.totalElements,
|
||||
@@ -368,11 +442,17 @@ export function CtyFldngPrvtnBizListPage() {
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
/** 확정된 조건으로 서버 목록을 갱신한다. */
|
||||
const load = useCallback(
|
||||
async (target = page, condition = applied) => {
|
||||
async (
|
||||
target = page,
|
||||
condition = applied,
|
||||
nextSort: UiTableSort | undefined = sort,
|
||||
) => {
|
||||
const query = new URLSearchParams({
|
||||
page: String(target - 1),
|
||||
size: String(PAGE_SIZE),
|
||||
sort: "ctyFldngPrvtnBizSn,desc",
|
||||
sort: nextSort
|
||||
? `${nextSort.key},${nextSort.direction}`
|
||||
: "ctyFldngPrvtnBizSn,desc",
|
||||
});
|
||||
Object.entries(condition).forEach(([k, v]) => {
|
||||
if (v.trim()) query.set(k, v.trim());
|
||||
@@ -404,7 +484,7 @@ export function CtyFldngPrvtnBizListPage() {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[applied, page],
|
||||
[applied, page, sort],
|
||||
);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -453,7 +533,10 @@ export function CtyFldngPrvtnBizListPage() {
|
||||
if (v.trim()) query.set(k, v.trim());
|
||||
});
|
||||
await downloadCtyFldngPrvtnExcel(
|
||||
await getAllCtyFldngPrvtnBusinesses(query),
|
||||
await getAllCtyFldngPrvtnBusinesses(
|
||||
query,
|
||||
sort ? `${sort.key},${sort.direction}` : undefined,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setNotice(e instanceof Error ? e.message : "엑셀을 내려받지 못했습니다.");
|
||||
@@ -478,6 +561,7 @@ export function CtyFldngPrvtnBizListPage() {
|
||||
setSggCd("0");
|
||||
setAppliedCtpvCd("0");
|
||||
setAppliedSggCd("0");
|
||||
setSort(undefined);
|
||||
setNotice("");
|
||||
setPage(1);
|
||||
router.replace("/oper/ctyFldngPrvtnBiz", { scroll: false });
|
||||
@@ -605,6 +689,7 @@ export function CtyFldngPrvtnBizListPage() {
|
||||
</Label>
|
||||
<Input
|
||||
className="w-[15rem] !text-[1.3rem]"
|
||||
list="cty-fldng-progress-suggestions"
|
||||
value={search.prplPrst}
|
||||
onChange={(event) =>
|
||||
setSearch((current) => ({
|
||||
@@ -613,6 +698,13 @@ export function CtyFldngPrvtnBizListPage() {
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<datalist id="cty-fldng-progress-suggestions">
|
||||
{businessProgressOptions.map((option) => (
|
||||
<option key={option.code} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -372,17 +372,19 @@ export const getPplSummaries = async (
|
||||
exmnBizId: string,
|
||||
params: { trtdstId?: string; page: number; size: number; sort?: string },
|
||||
): Promise<ExmnPage<PplSummaryRow>> => {
|
||||
const query = new URLSearchParams({
|
||||
page: String(params.page),
|
||||
size: String(params.size),
|
||||
});
|
||||
if (params.trtdstId) query.set("trtdstId", params.trtdstId);
|
||||
if (params.sort) query.set("sort", params.sort);
|
||||
return toPage<PplSummaryRow>(
|
||||
await getData(
|
||||
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/summaries/ppl?${query}`,
|
||||
),
|
||||
);
|
||||
const summaries = await getExmnSectionSummaries(exmnBizId, "PPL");
|
||||
const filtered = params.trtdstId
|
||||
? summaries.filter((row) => row.trtdstId === params.trtdstId)
|
||||
: summaries;
|
||||
const start = Math.max(params.page, 0) * Math.max(params.size, 1);
|
||||
const size = Math.max(params.size, 1);
|
||||
return {
|
||||
content: filtered.slice(start, start + size),
|
||||
totalElements: filtered.length,
|
||||
number: Math.max(params.page, 0),
|
||||
size,
|
||||
totalPages: Math.ceil(filtered.length / size),
|
||||
};
|
||||
};
|
||||
|
||||
/** 선택 사업의 PPL 시설을 처리분구·구간·GIS번호·등급으로 조회한다. */
|
||||
@@ -408,17 +410,3 @@ export const getPplFacilities = async (
|
||||
if (params.sort) query.set("sort", params.sort);
|
||||
return toPage<PplFacility>(await getData(`v1/exmnFacilities?${query}`));
|
||||
};
|
||||
|
||||
/** 현재 PPL 조회조건을 서버 엑셀로 내려받는다. */
|
||||
export const downloadPplExport = async (
|
||||
exmnBizId: string,
|
||||
trtdstId?: string,
|
||||
): Promise<Blob> => {
|
||||
const query = new URLSearchParams();
|
||||
if (trtdstId) query.set("trtdstId", trtdstId);
|
||||
const queryString = query.toString();
|
||||
const suffix = queryString ? `?${queryString}` : "";
|
||||
return getBlobData(
|
||||
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/exports/ppl${suffix}`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
getCommonCodes,
|
||||
type CommonCodeItem,
|
||||
} from "@/components/lib/common/apiHelpers";
|
||||
|
||||
type CodeLabelMap = Record<string, string>;
|
||||
type ExmnCodeGroup =
|
||||
| "EXMNDFCGRD"
|
||||
| "EXMNRECDTYPE"
|
||||
| "EXMNDFCTYPE"
|
||||
| "EXMNDFCSEV"
|
||||
| "EXMNSTTSGRP"
|
||||
| "PPLEXMNITEM"
|
||||
| "MNHLEXMNITEM";
|
||||
|
||||
const COMMON_CODE_GROUPS: ExmnCodeGroup[] = [
|
||||
"EXMNDFCGRD",
|
||||
"EXMNRECDTYPE",
|
||||
"EXMNDFCTYPE",
|
||||
"EXMNDFCSEV",
|
||||
"EXMNSTTSGRP",
|
||||
"PPLEXMNITEM",
|
||||
"MNHLEXMNITEM",
|
||||
];
|
||||
|
||||
const cachedCodeGroups = new Map<ExmnCodeGroup, CommonCodeItem[]>();
|
||||
const pendingCodeGroups = new Map<
|
||||
ExmnCodeGroup,
|
||||
Promise<CommonCodeItem[]>
|
||||
>();
|
||||
|
||||
const loadCodeGroup = async (upCd: ExmnCodeGroup) => {
|
||||
const cached = cachedCodeGroups.get(upCd);
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = pendingCodeGroups.get(upCd);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = getCommonCodes(upCd)
|
||||
.then((codes) => {
|
||||
cachedCodeGroups.set(upCd, codes);
|
||||
return codes;
|
||||
})
|
||||
.finally(() => {
|
||||
pendingCodeGroups.delete(upCd);
|
||||
});
|
||||
|
||||
pendingCodeGroups.set(upCd, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
const toLabelMap = (codes: CommonCodeItem[]): CodeLabelMap =>
|
||||
Object.fromEntries(codes.map((code) => [code.lwpoCd, code.lwpoCdNm]));
|
||||
|
||||
/** 조사 코드값과 표 열 순서는 유지하고 화면에서 사용할 CMS 코드명만 제공한다. */
|
||||
export const useExmnCommonCodes = () => {
|
||||
const [codeGroups, setCodeGroups] = useState<
|
||||
Partial<Record<ExmnCodeGroup, CommonCodeItem[]>>
|
||||
>(
|
||||
() =>
|
||||
Object.fromEntries(cachedCodeGroups.entries()) as Partial<
|
||||
Record<ExmnCodeGroup, CommonCodeItem[]>
|
||||
>,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
COMMON_CODE_GROUPS.forEach((upCd) => {
|
||||
loadCodeGroup(upCd)
|
||||
.then((codes) => {
|
||||
if (!cancelled) {
|
||||
setCodeGroups((current) => ({ ...current, [upCd]: codes }));
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`${upCd} 공통코드 조회에 실패했습니다.`, error);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const defectGradeOptions = useMemo(
|
||||
() =>
|
||||
codeGroups.EXMNDFCGRD?.map((code) => ({
|
||||
value: code.lwpoCd,
|
||||
label: code.lwpoCdNm,
|
||||
})) ??
|
||||
[1, 2, 3, 4, 5].map((grade) => ({
|
||||
value: String(grade),
|
||||
label: `${grade}등급`,
|
||||
})),
|
||||
[codeGroups.EXMNDFCGRD],
|
||||
);
|
||||
|
||||
const recordTypeLabels = useMemo(
|
||||
() => toLabelMap(codeGroups.EXMNRECDTYPE ?? []),
|
||||
[codeGroups.EXMNRECDTYPE],
|
||||
);
|
||||
const defectTypeLabels = useMemo(
|
||||
() => toLabelMap(codeGroups.EXMNDFCTYPE ?? []),
|
||||
[codeGroups.EXMNDFCTYPE],
|
||||
);
|
||||
const severityLabels = useMemo(
|
||||
() => toLabelMap(codeGroups.EXMNDFCSEV ?? []),
|
||||
[codeGroups.EXMNDFCSEV],
|
||||
);
|
||||
const statusGroupLabels = useMemo(
|
||||
() => toLabelMap(codeGroups.EXMNSTTSGRP ?? []),
|
||||
[codeGroups.EXMNSTTSGRP],
|
||||
);
|
||||
const pipeItemLabels = useMemo(
|
||||
() => toLabelMap(codeGroups.PPLEXMNITEM ?? []),
|
||||
[codeGroups.PPLEXMNITEM],
|
||||
);
|
||||
const manholeItemLabels = useMemo(
|
||||
() => toLabelMap(codeGroups.MNHLEXMNITEM ?? []),
|
||||
[codeGroups.MNHLEXMNITEM],
|
||||
);
|
||||
|
||||
return {
|
||||
defectGradeOptions,
|
||||
recordTypeLabels,
|
||||
defectTypeLabels,
|
||||
severityLabels,
|
||||
statusGroupLabels,
|
||||
pipeItemLabels,
|
||||
manholeItemLabels,
|
||||
};
|
||||
};
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { getStartYearArray } from "@/components/ui/YearRangeSelectBox";
|
||||
import useStore from "@/store/useStore";
|
||||
import {
|
||||
postPopupMessage,
|
||||
postPopupMessageAndClose,
|
||||
} from "@/features/windowPopup/lib/popupMessage";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
|
||||
import { postPopupMessage } from "@/features/windowPopup/lib/popupMessage";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
||||
import {
|
||||
StandardFormField,
|
||||
@@ -22,8 +20,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@common/components";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
createExmnBusiness,
|
||||
createExmnTemplates,
|
||||
@@ -40,6 +37,10 @@ import type {
|
||||
} from "../model/exmn-business-types";
|
||||
import type { TrtdstSettingDto } from "../model/trtdst-types";
|
||||
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { getStartYearArray } from "@/components/ui/YearRangeSelectBox";
|
||||
import useStore from "@/store/useStore";
|
||||
|
||||
type Notice = { type: "success" | "error" | "info"; text: string } | null;
|
||||
|
||||
interface ExmnBusinessRegisterPopupPageProps {
|
||||
@@ -52,14 +53,16 @@ const sortDistricts = (items: TrtdstSettingDto[]) =>
|
||||
left.trtdstNm.trim().localeCompare(right.trtdstNm.trim(), "ko"),
|
||||
);
|
||||
|
||||
/** Blob을 브라우저 다운로드로 전달하고 임시 URL을 즉시 정리한다. */
|
||||
/** Blob 다운로드 링크를 DOM에 연결하고 브라우저가 수락한 뒤 임시 URL을 정리한다. */
|
||||
const saveBlob = (blob: Blob, fileName: string) => {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = fileName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1_000);
|
||||
};
|
||||
|
||||
/** 공통 WindowPopup 안에서 사업정보·대상 처리분구·조사양식을 등록·수정하는 화면이다. */
|
||||
@@ -92,6 +95,11 @@ export function ExmnBusinessRegisterPopupPage({
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
const formLocked = Boolean(savedBusiness) && !isEditMode;
|
||||
/** 저장 전 비활성 양식버튼에 표시할 업무 안내와 활성 조건이다. */
|
||||
const templateDownloadReady = Boolean(savedBusiness?.exmnBizId);
|
||||
const templateDownloadGuide = templateDownloadReady
|
||||
? undefined
|
||||
: "사업정보와 처리분구를 먼저 저장해주세요.";
|
||||
|
||||
/** 팝업 레이아웃은 BaseLayout 초기화를 거치지 않으므로 행정구역 공통코드를 직접 준비한다. */
|
||||
useEffect(() => {
|
||||
@@ -326,10 +334,14 @@ export function ExmnBusinessRegisterPopupPage({
|
||||
exmnBizId: persisted.exmnBizId,
|
||||
};
|
||||
if (!isEditMode) {
|
||||
postPopupMessageAndClose(
|
||||
postPopupMessage(
|
||||
WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_CREATED,
|
||||
popupPayload,
|
||||
);
|
||||
setNotice({
|
||||
type: "success",
|
||||
text: "사업정보를 등록했습니다. 조사 입력양식과 조사보고서 양식을 다운로드할 수 있습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -416,29 +428,36 @@ export function ExmnBusinessRegisterPopupPage({
|
||||
showDefaultCancelAction={false}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
disabled={isLoading}
|
||||
onClick={() => void handleDownloadTemplates()}
|
||||
<span
|
||||
className="inline-flex"
|
||||
title={templateDownloadGuide}
|
||||
>
|
||||
양식다운로드
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
disabled={isLoading}
|
||||
onClick={() => void handleReportTemplate()}
|
||||
>
|
||||
조사보고서 양식다운로드
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
disabled={isLoading || !templateDownloadReady}
|
||||
onClick={() => void handleDownloadTemplates()}
|
||||
>
|
||||
양식다운로드
|
||||
</Button>
|
||||
</span>
|
||||
{!isEditMode ? (
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
disabled={isLoading}
|
||||
onClick={() => void handleReportTemplate()}
|
||||
>
|
||||
조사보고서 양식다운로드
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
disabled={isLoading || formLocked}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{isEditMode ? "수정" : "저장"}
|
||||
저장
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Table } from "@common/components";
|
||||
|
||||
import {
|
||||
type ExmnDetailSeverity,
|
||||
type ExmnPplUnitDetailRow,
|
||||
} from "../model/exmn-detail-table";
|
||||
import { useExmnCommonCodes } from "../lib/useExmnCommonCodes";
|
||||
|
||||
const SEVERITY_LABELS: Record<ExmnDetailSeverity, string> = {
|
||||
const DEFAULT_SEVERITY_LABELS: Record<ExmnDetailSeverity, string> = {
|
||||
L: "대",
|
||||
M: "중",
|
||||
S: "소",
|
||||
@@ -52,6 +55,13 @@ export function ExmnPplUnitDetailTable({
|
||||
rows,
|
||||
emptyMessage,
|
||||
}: ExmnPplUnitDetailTableProps) {
|
||||
// 본단위 행의 기록유형·심각도·관로항목 표시명만 CMS 코드명으로 교체한다.
|
||||
const { recordTypeLabels, severityLabels: cmsSeverityLabels, pipeItemLabels } =
|
||||
useExmnCommonCodes();
|
||||
const severityLabels: Record<ExmnDetailSeverity, string> = {
|
||||
...DEFAULT_SEVERITY_LABELS,
|
||||
...cmsSeverityLabels,
|
||||
};
|
||||
const pipeSpans = buildRowSpanMap(rows, (row) => row.pipeNo);
|
||||
const unitSpans = buildRowSpanMap(
|
||||
rows,
|
||||
@@ -60,196 +70,197 @@ export function ExmnPplUnitDetailTable({
|
||||
const tableWidth = COLUMN_WIDTHS.reduce((sum, width) => sum + width, 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-x-auto overflow-y-visible border border-border bg-background">
|
||||
<table
|
||||
className="border-separate border-spacing-0 text-center text-[1.05rem] text-foreground"
|
||||
style={{ width: "100%", minWidth: `${tableWidth}px` }}
|
||||
>
|
||||
<colgroup>
|
||||
{COLUMN_WIDTHS.map((width, index) => (
|
||||
<col key={`unit-column-${index}`} style={{ width: `${width}px` }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead className="sticky top-0 z-30 bg-primary text-center text-[1.2rem] font-medium leading-[1.2] text-primary-foreground">
|
||||
<tr>
|
||||
<th
|
||||
rowSpan={2}
|
||||
className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground"
|
||||
>
|
||||
관로번호
|
||||
</th>
|
||||
<th
|
||||
rowSpan={2}
|
||||
className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground"
|
||||
>
|
||||
단위
|
||||
<br />
|
||||
구간
|
||||
</th>
|
||||
<th
|
||||
rowSpan={2}
|
||||
className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground"
|
||||
>
|
||||
단위
|
||||
<br />
|
||||
길이
|
||||
<br />
|
||||
(m)
|
||||
</th>
|
||||
<th
|
||||
rowSpan={2}
|
||||
className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground"
|
||||
>
|
||||
결함
|
||||
<br />
|
||||
위치
|
||||
<br />
|
||||
(m)
|
||||
</th>
|
||||
<th
|
||||
colSpan={6}
|
||||
className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground"
|
||||
>
|
||||
결함
|
||||
</th>
|
||||
<th
|
||||
colSpan={2}
|
||||
className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground"
|
||||
>
|
||||
등급점수(점)
|
||||
</th>
|
||||
<th
|
||||
rowSpan={2}
|
||||
className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground"
|
||||
>
|
||||
조사중단사유
|
||||
</th>
|
||||
<th
|
||||
rowSpan={2}
|
||||
className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground"
|
||||
>
|
||||
비고
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground">
|
||||
종류
|
||||
</th>
|
||||
<th className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground">
|
||||
코드
|
||||
</th>
|
||||
<th className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground">
|
||||
등급
|
||||
</th>
|
||||
<th className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground">
|
||||
발생
|
||||
<br />
|
||||
위치
|
||||
</th>
|
||||
<th className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground">
|
||||
침입수
|
||||
<br />
|
||||
가점
|
||||
</th>
|
||||
<th className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground">
|
||||
연속
|
||||
<br />
|
||||
결함
|
||||
</th>
|
||||
<th className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground">
|
||||
구조적
|
||||
</th>
|
||||
<th className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground">
|
||||
운영적
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length > 0 ? (
|
||||
rows.map((row, index) => {
|
||||
const pipeSpan = pipeSpans.get(row.pipeNo);
|
||||
const unitKey = `${row.pipeNo}::${row.unitSeq}`;
|
||||
const unitSpan = unitSpans.get(unitKey);
|
||||
const showPipeCell = pipeSpan?.firstIndex === index;
|
||||
const showUnitCells = unitSpan?.firstIndex === index;
|
||||
<Table
|
||||
variant="default"
|
||||
className="border-separate border-spacing-0 text-center text-foreground"
|
||||
style={{ width: "100%", minWidth: `${tableWidth}px` }}
|
||||
>
|
||||
<colgroup>
|
||||
{COLUMN_WIDTHS.map((width, index) => (
|
||||
<col key={`unit-column-${index}`} style={{ width: `${width}px` }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead className="sticky top-0 z-30 text-center font-medium leading-[1.2]">
|
||||
<tr>
|
||||
<th
|
||||
rowSpan={2}
|
||||
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
|
||||
>
|
||||
관로번호
|
||||
</th>
|
||||
<th
|
||||
rowSpan={2}
|
||||
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
|
||||
>
|
||||
단위
|
||||
<br />
|
||||
구간
|
||||
</th>
|
||||
<th
|
||||
rowSpan={2}
|
||||
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
|
||||
>
|
||||
단위
|
||||
<br />
|
||||
길이
|
||||
<br />
|
||||
(m)
|
||||
</th>
|
||||
<th
|
||||
rowSpan={2}
|
||||
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
|
||||
>
|
||||
결함
|
||||
<br />
|
||||
위치
|
||||
<br />
|
||||
(m)
|
||||
</th>
|
||||
<th
|
||||
colSpan={6}
|
||||
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
|
||||
>
|
||||
결함
|
||||
</th>
|
||||
<th
|
||||
colSpan={2}
|
||||
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
|
||||
>
|
||||
등급점수(점)
|
||||
</th>
|
||||
<th
|
||||
rowSpan={2}
|
||||
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
|
||||
>
|
||||
조사중단사유
|
||||
</th>
|
||||
<th
|
||||
rowSpan={2}
|
||||
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
|
||||
>
|
||||
비고
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
|
||||
종류
|
||||
</th>
|
||||
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
|
||||
코드
|
||||
</th>
|
||||
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
|
||||
등급
|
||||
</th>
|
||||
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
|
||||
발생
|
||||
<br />
|
||||
위치
|
||||
</th>
|
||||
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
|
||||
침입수
|
||||
<br />
|
||||
가점
|
||||
</th>
|
||||
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
|
||||
연속
|
||||
<br />
|
||||
결함
|
||||
</th>
|
||||
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
|
||||
구조적
|
||||
</th>
|
||||
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
|
||||
운영적
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length > 0 ? (
|
||||
rows.map((row, index) => {
|
||||
const pipeSpan = pipeSpans.get(row.pipeNo);
|
||||
const unitKey = `${row.pipeNo}::${row.unitSeq}`;
|
||||
const unitSpan = unitSpans.get(unitKey);
|
||||
const showPipeCell = pipeSpan?.firstIndex === index;
|
||||
const showUnitCells = unitSpan?.firstIndex === index;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={`${row.pipeNo}-${row.unitSeq}-${row.defectPositionM}-${row.code}-${index}`}
|
||||
className="transition-colors hover:bg-container"
|
||||
>
|
||||
{showPipeCell && (
|
||||
<td
|
||||
rowSpan={pipeSpan?.count}
|
||||
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle font-medium text-primary"
|
||||
>
|
||||
{row.pipeNo}
|
||||
</td>
|
||||
)}
|
||||
{showUnitCells && (
|
||||
<>
|
||||
<td
|
||||
rowSpan={unitSpan?.count}
|
||||
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle font-medium"
|
||||
>
|
||||
{row.unitSeq}
|
||||
</td>
|
||||
<td
|
||||
rowSpan={unitSpan?.count}
|
||||
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle"
|
||||
>
|
||||
{row.unitLengthM}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
<td className="border-b border-r border-border px-2 py-2.5">
|
||||
{formatOptionalValue(row.defectPositionM)}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5 text-left">
|
||||
{row.defectName}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">
|
||||
{row.code}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">
|
||||
{row.severity ? SEVERITY_LABELS[row.severity] : "-"}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">
|
||||
{formatOptionalValue(row.occurrencePosition)}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">
|
||||
{formatOptionalValue(row.infiltrationBonus)}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">
|
||||
{row.continuous == null ? "-" : row.continuous ? "Y" : "N"}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">
|
||||
{formatOptionalValue(row.structuralScore)}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">
|
||||
{formatOptionalValue(row.operationalScore)}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">
|
||||
{formatOptionalValue(row.surveyStopReason)}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">
|
||||
{formatOptionalValue(row.remark)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={COLUMN_WIDTHS.length}
|
||||
className="h-28 border-b border-r border-border text-center text-muted-foreground"
|
||||
return (
|
||||
<tr
|
||||
key={`${row.pipeNo}-${row.unitSeq}-${row.defectPositionM}-${row.code}-${index}`}
|
||||
className="transition-colors hover:bg-container"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{showPipeCell && (
|
||||
<td
|
||||
rowSpan={pipeSpan?.count}
|
||||
className="border-b border-r border-border bg-background px-2 py-[1.2rem] align-middle font-medium text-primary"
|
||||
>
|
||||
{row.pipeNo}
|
||||
</td>
|
||||
)}
|
||||
{showUnitCells && (
|
||||
<>
|
||||
<td
|
||||
rowSpan={unitSpan?.count}
|
||||
className="border-b border-r border-border bg-background px-2 py-[1.2rem] align-middle font-medium"
|
||||
>
|
||||
{row.unitSeq}
|
||||
</td>
|
||||
<td
|
||||
rowSpan={unitSpan?.count}
|
||||
className="border-b border-r border-border bg-background px-2 py-[1.2rem] align-middle"
|
||||
>
|
||||
{row.unitLengthM}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
<td className="border-b border-r border-border px-2 py-[1.2rem]">
|
||||
{formatOptionalValue(row.defectPositionM)}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-[1.2rem] text-left">
|
||||
{row.recordType
|
||||
? `(${recordTypeLabels[row.recordType] || row.recordType})${pipeItemLabels[row.code] || row.defectName.replace(/^\([^)]*\)/, "")}`
|
||||
: row.defectName}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-[1.2rem]">
|
||||
{row.code}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-[1.2rem]">
|
||||
{row.severity ? severityLabels[row.severity] : "-"}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-[1.2rem]">
|
||||
{formatOptionalValue(row.occurrencePosition)}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-[1.2rem]">
|
||||
{formatOptionalValue(row.infiltrationBonus)}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-[1.2rem]">
|
||||
{row.continuous == null ? "-" : row.continuous ? "Y" : "N"}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-[1.2rem]">
|
||||
{formatOptionalValue(row.structuralScore)}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-[1.2rem]">
|
||||
{formatOptionalValue(row.operationalScore)}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-[1.2rem]">
|
||||
{formatOptionalValue(row.surveyStopReason)}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-[1.2rem]">
|
||||
{formatOptionalValue(row.remark)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={COLUMN_WIDTHS.length}
|
||||
className="h-28 border-b border-r border-border text-center text-muted-foreground"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,11 +19,12 @@ import {
|
||||
import {
|
||||
STANDARD_ROW_NUMBER_HEADER,
|
||||
StandardDataTable,
|
||||
StandardTableSortHeader,
|
||||
getStandardRowNumber,
|
||||
type UiTableSort,
|
||||
} from "@common/business_components";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type PaginationState,
|
||||
@@ -40,20 +41,23 @@ import type {
|
||||
import { ExmnStatusDetailPanel } from "./ExmnStatusDetailPanel";
|
||||
import { EXMN_TABLE_HEADER_CLASS } from "./ExmnStandardDataTable";
|
||||
|
||||
// 서버 호환을 위해 전체 후보를 한 번에 받고 공통 표에서 10건씩 페이지 표시한다.
|
||||
const BUSINESS_LIST_SCROLL_FETCH_SIZE = 1000;
|
||||
const BUSINESS_LIST_PAGE_SIZE = 10;
|
||||
|
||||
const BUSINESS_TABLE_HEAD = [
|
||||
{ id: "rowNumber", title: STANDARD_ROW_NUMBER_HEADER },
|
||||
{ id: "bizCyclText", title: "사업차수" },
|
||||
{ id: "bizYr", title: "사업년도" },
|
||||
{ id: "ctpvNm", title: "시도" },
|
||||
{ id: "sggNm", title: "시군구" },
|
||||
{ id: "bizNm", title: "사업명" },
|
||||
{ id: "trtdstCntText", title: "처리구역·분구(개소)" },
|
||||
{ id: "prcisExmnPrlgText", title: "정밀조사 연장(km)" },
|
||||
{ id: "reportYnText", title: "보고서" },
|
||||
{ id: "rowNumber", title: STANDARD_ROW_NUMBER_HEADER, sortKey: "exmnBizId" },
|
||||
{ id: "bizCyclText", title: "사업차수", sortKey: "bizCycl" },
|
||||
{ id: "bizYr", title: "사업년도", sortKey: "bizYr" },
|
||||
{ id: "ctpvNm", title: "시도", sortKey: "ctpvNm" },
|
||||
{ id: "sggNm", title: "시군구", sortKey: "sggNm" },
|
||||
{ id: "bizNm", title: "사업명", sortKey: "bizNm" },
|
||||
{ id: "trtdstCntText", title: "처리구역·분구(개소)", sortKey: "trtdstCnt" },
|
||||
{
|
||||
id: "prcisExmnPrlgText",
|
||||
title: "정밀조사 연장(km)",
|
||||
sortKey: "prcisExmnPrlg",
|
||||
},
|
||||
// 첨부서비스에서 현재 페이지 파일명을 보강하므로 전체 결과 파일명 정렬은 지원하지 않는다.
|
||||
{ id: "reportYnText", title: "보고서", sortKey: null },
|
||||
];
|
||||
|
||||
interface BusinessFilters {
|
||||
@@ -94,6 +98,8 @@ export function ExmnRegistrationPage() {
|
||||
pageIndex: 0,
|
||||
pageSize: BUSINESS_LIST_PAGE_SIZE,
|
||||
});
|
||||
// 표시명 컬럼도 같은 이름 필드를 사용하는 서버 단일 정렬 상태다.
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
// 선택 사업 ID는 서버 재조회 뒤에도 같은 행을 복원할 수 있도록 객체와 별도로 유지한다.
|
||||
const [selectedBusinessId, setSelectedBusinessId] = useState<string | null>(
|
||||
null,
|
||||
@@ -143,6 +149,8 @@ export function ExmnRegistrationPage() {
|
||||
rowNumber: getStandardRowNumber({
|
||||
total: totalElements,
|
||||
rowIndex: index,
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
}),
|
||||
bizCyclText: business.bizCycl == null ? "-" : `${business.bizCycl}차`,
|
||||
trtdstCntText: Number(business.trtdstCnt ?? 0).toLocaleString("ko-KR"),
|
||||
@@ -156,35 +164,47 @@ export function ExmnRegistrationPage() {
|
||||
? "있음"
|
||||
: "없음"),
|
||||
})),
|
||||
[businesses, totalElements],
|
||||
[businesses, pagination.pageIndex, pagination.pageSize, totalElements],
|
||||
);
|
||||
|
||||
/** 화면 조건을 행정구역 이름과 목록 내부 스크롤용 단일 서버 Page 계약으로 변환한다. */
|
||||
/** 화면 조건과 현재 정렬을 0-base 서버 Page 계약으로 변환한다. */
|
||||
const buildSearchParams = (
|
||||
filters: BusinessFilters,
|
||||
pageIndex: number,
|
||||
nextSort?: UiTableSort,
|
||||
): ExmnBusinessSearchParams => ({
|
||||
bizCycl: filters.bizCycl || undefined,
|
||||
bizYr: filters.bizYr !== "0" ? filters.bizYr : undefined,
|
||||
ctpvNm: filters.ctpvNm || undefined,
|
||||
sggNm: filters.sggNm || undefined,
|
||||
bizNm: filters.bizNm || undefined,
|
||||
page: 0,
|
||||
size: BUSINESS_LIST_SCROLL_FETCH_SIZE,
|
||||
page: pageIndex,
|
||||
size: BUSINESS_LIST_PAGE_SIZE,
|
||||
sort: nextSort ? `${nextSort.key},${nextSort.direction}` : undefined,
|
||||
});
|
||||
|
||||
/** 지정 조회조건으로 사업 목록을 읽고 선택 행을 가능한 경우 유지한다. */
|
||||
const fetchBusinesses = async (filters: BusinessFilters) => {
|
||||
/** 지정 조회조건·정렬·페이지로 사업 목록을 읽고 현재 페이지 선택을 복원한다. */
|
||||
const fetchBusinesses = async (
|
||||
filters: BusinessFilters,
|
||||
pageIndex = 0,
|
||||
nextSort: UiTableSort | undefined = sort,
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await getExmnBusinesses(buildSearchParams(filters));
|
||||
const result = await getExmnBusinesses(
|
||||
buildSearchParams(filters, pageIndex, nextSort),
|
||||
);
|
||||
const content = result.content;
|
||||
setBusinesses(content);
|
||||
setTotalElements(result.totalElements);
|
||||
setSelectedBusinessId((current) =>
|
||||
content.some((item) => item.exmnBizId === current) ? current : null,
|
||||
);
|
||||
setPagination((current) => ({ ...current, pageIndex: 0 }));
|
||||
setPagination((current) => ({
|
||||
...current,
|
||||
pageIndex: result.number,
|
||||
}));
|
||||
return true;
|
||||
} catch (error) {
|
||||
setBusinesses([]);
|
||||
@@ -207,7 +227,7 @@ export function ExmnRegistrationPage() {
|
||||
useEffect(() => {
|
||||
if (initialBusinessesLoadedRef.current) return;
|
||||
initialBusinessesLoadedRef.current = true;
|
||||
void fetchBusinesses(EMPTY_FILTERS);
|
||||
void fetchBusinesses(EMPTY_FILTERS, 0, undefined);
|
||||
// 최초 진입 시점의 빈 조회조건으로 한 번만 실행한다.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -215,7 +235,7 @@ export function ExmnRegistrationPage() {
|
||||
/** 입력 중 조건을 확정하고 첫 페이지를 조회한다. */
|
||||
const handleSearch = async () => {
|
||||
setAppliedFilters(draftFilters);
|
||||
await fetchBusinesses(draftFilters);
|
||||
await fetchBusinesses(draftFilters, 0, sort);
|
||||
};
|
||||
|
||||
/** CS 초기화 버튼으로 모든 검색조건과 페이지를 최초 조회 기준으로 복원한다. */
|
||||
@@ -223,7 +243,8 @@ export function ExmnRegistrationPage() {
|
||||
setDraftFilters(EMPTY_FILTERS);
|
||||
setAppliedFilters(EMPTY_FILTERS);
|
||||
setSelectedBusinessId(null);
|
||||
await fetchBusinesses(EMPTY_FILTERS);
|
||||
setSort(undefined);
|
||||
await fetchBusinesses(EMPTY_FILTERS, 0, undefined);
|
||||
};
|
||||
|
||||
/** 목록의 보고서 링크에서 첨부서비스 원본 PDF를 내려받는다. */
|
||||
@@ -332,7 +353,7 @@ export function ExmnRegistrationPage() {
|
||||
event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_CREATED
|
||||
) {
|
||||
setSelectedBusinessId(null);
|
||||
void fetchBusinesses(appliedFilters).then((refreshed) => {
|
||||
void fetchBusinesses(appliedFilters, 0, sort).then((refreshed) => {
|
||||
if (refreshed)
|
||||
setNotice({ type: "success", text: "조사사업이 등록되었습니다." });
|
||||
});
|
||||
@@ -342,7 +363,11 @@ export function ExmnRegistrationPage() {
|
||||
if (
|
||||
event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_UPDATED
|
||||
) {
|
||||
void fetchBusinesses(appliedFilters).then((refreshed) => {
|
||||
void fetchBusinesses(
|
||||
appliedFilters,
|
||||
pagination.pageIndex,
|
||||
sort,
|
||||
).then((refreshed) => {
|
||||
if (refreshed)
|
||||
setNotice({ type: "success", text: "조사사업이 수정되었습니다." });
|
||||
});
|
||||
@@ -352,7 +377,11 @@ export function ExmnRegistrationPage() {
|
||||
if (
|
||||
event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED
|
||||
) {
|
||||
void fetchBusinesses(appliedFilters).then((refreshed) => {
|
||||
void fetchBusinesses(
|
||||
appliedFilters,
|
||||
pagination.pageIndex,
|
||||
sort,
|
||||
).then((refreshed) => {
|
||||
if (refreshed)
|
||||
setNotice({
|
||||
type: "success",
|
||||
@@ -364,7 +393,14 @@ export function ExmnRegistrationPage() {
|
||||
|
||||
window.addEventListener("message", handlePopupMessage);
|
||||
return () => window.removeEventListener("message", handlePopupMessage);
|
||||
}, [appliedFilters]);
|
||||
}, [appliedFilters, pagination.pageIndex, sort]);
|
||||
|
||||
/** 정렬 변경 시 첫 페이지와 선택 사업을 초기화하고 서버 전체 결과를 다시 조회한다. */
|
||||
const handleBusinessSortChange = (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setSelectedBusinessId(null);
|
||||
void fetchBusinesses(appliedFilters, 0, nextSort);
|
||||
};
|
||||
|
||||
/** 보고서 다운로드와 행 선택을 유지하며 조사사업 목록을 공통 DataTable 열로 변환한다. */
|
||||
const columns = useMemo<ColumnDef<(typeof businessRows)[number]>[]>(
|
||||
@@ -372,7 +408,17 @@ export function ExmnRegistrationPage() {
|
||||
BUSINESS_TABLE_HEAD.map((head) => ({
|
||||
id: head.id,
|
||||
accessorFn: (row) => row[head.id as keyof typeof row],
|
||||
header: head.title,
|
||||
header: () =>
|
||||
head.sortKey === null ? (
|
||||
head.title
|
||||
) : (
|
||||
<StandardTableSortHeader
|
||||
label={head.title}
|
||||
sortKey={head.sortKey}
|
||||
sort={sort}
|
||||
onSortChange={handleBusinessSortChange}
|
||||
/>
|
||||
),
|
||||
cell: ({ row, getValue }) => {
|
||||
const business = row.original;
|
||||
const isReportCell = head.id === "reportYnText";
|
||||
@@ -420,7 +466,7 @@ export function ExmnRegistrationPage() {
|
||||
align: "center" as const,
|
||||
},
|
||||
})),
|
||||
[],
|
||||
[appliedFilters, sort],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -431,9 +477,16 @@ export function ExmnRegistrationPage() {
|
||||
rowSelection: selectedBusinessId ? { [selectedBusinessId]: true } : {},
|
||||
},
|
||||
getRowId: (row) => row.exmnBizId,
|
||||
onPaginationChange: setPagination,
|
||||
rowCount: totalElements,
|
||||
manualPagination: true,
|
||||
onPaginationChange: (updater) => {
|
||||
const next =
|
||||
typeof updater === "function" ? updater(pagination) : updater;
|
||||
if (next.pageIndex === pagination.pageIndex) return;
|
||||
setSelectedBusinessId(null);
|
||||
void fetchBusinesses(appliedFilters, next.pageIndex, sort);
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -633,7 +686,7 @@ export function ExmnRegistrationPage() {
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || !selectedBusiness}
|
||||
onClick={openBusinessUpdate}
|
||||
>
|
||||
수정
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
|
||||
import {
|
||||
StandardTableSortHeader,
|
||||
sortStandardTableRows,
|
||||
type UiTableSort,
|
||||
} from "@common/business_components";
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
@@ -28,23 +33,23 @@ import { EXMN_TABLE_HEADER_HEIGHT_REM } from "./ExmnStandardDataTable";
|
||||
const SUMMARY_GRID_ROW_HEIGHT_REM = 3.2;
|
||||
|
||||
const PIPE_SUMMARY_TABLE_HEAD = [
|
||||
{ id: "rowNumber", title: "순번", widthPercent: 6 },
|
||||
{ id: "trtdstNm", title: "처리분구", widthPercent: 18 },
|
||||
{ id: "trgtPrlgText", title: "대상연장(m)", widthPercent: 13 },
|
||||
{ id: "drvngDstncText", title: "주행거리(m)", widthPercent: 13 },
|
||||
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 15 },
|
||||
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 14 },
|
||||
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 14 },
|
||||
{ id: "etcCountText", title: "기타", widthPercent: 7 },
|
||||
{ id: "rowNumber", title: "순번", widthPercent: 6, sortKey: "rowNumber" },
|
||||
{ id: "trtdstNm", title: "처리분구", widthPercent: 18, sortKey: "trtdstNm" },
|
||||
{ id: "trgtPrlgText", title: "대상연장(m)", widthPercent: 13, sortKey: "trgtPrlgText" },
|
||||
{ id: "drvngDstncText", title: "주행거리(m)", widthPercent: 13, sortKey: "drvngDstncText" },
|
||||
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 15, sortKey: "wholeCountText" },
|
||||
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 14, sortKey: "structuralCountText" },
|
||||
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 14, sortKey: "operationalCountText" },
|
||||
{ id: "etcCountText", title: "기타", widthPercent: 7, sortKey: "etcCountText" },
|
||||
];
|
||||
|
||||
const MANHOLE_SUMMARY_TABLE_HEAD = [
|
||||
{ id: "rowNumber", title: "순번", widthPercent: 8 },
|
||||
{ id: "trtdstNm", title: "처리분구", widthPercent: 28 },
|
||||
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 18 },
|
||||
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 17 },
|
||||
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 17 },
|
||||
{ id: "etcCountText", title: "기타", widthPercent: 12 },
|
||||
{ id: "rowNumber", title: "순번", widthPercent: 8, sortKey: "rowNumber" },
|
||||
{ id: "trtdstNm", title: "처리분구", widthPercent: 28, sortKey: "trtdstNm" },
|
||||
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 18, sortKey: "wholeCountText" },
|
||||
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 17, sortKey: "structuralCountText" },
|
||||
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 17, sortKey: "operationalCountText" },
|
||||
{ id: "etcCountText", title: "기타", widthPercent: 12, sortKey: "etcCountText" },
|
||||
];
|
||||
|
||||
type Notice = { type: "info" | "error"; text: string } | null;
|
||||
@@ -88,6 +93,8 @@ export function ExmnStatusDetailPanel({
|
||||
const [summaryRows, setSummaryRows] = useState<ExmnSectionSummaryRow[]>([]);
|
||||
/** 상세보기 팝업에 전달할 현재 선택 처리분구다. */
|
||||
const [selectedTrtdstId, setSelectedTrtdstId] = useState<string | null>(null);
|
||||
// 서버가 반환한 전체 처리분구 집계행에 적용하는 단일 로컬 정렬 상태다.
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
@@ -96,11 +103,13 @@ export function ExmnStatusDetailPanel({
|
||||
useEffect(() => {
|
||||
setJobType("PPL");
|
||||
setSelectedTrtdstId(null);
|
||||
setSort(undefined);
|
||||
setNotice(null);
|
||||
}, [business?.exmnBizId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTrtdstId(null);
|
||||
setSort(undefined);
|
||||
}, [jobType]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -176,7 +185,7 @@ export function ExmnStatusDetailPanel({
|
||||
etc: 0,
|
||||
},
|
||||
);
|
||||
const rows: DisplaySummaryRow[] = summaryRows.map((row, index) => {
|
||||
const displayRows: DisplaySummaryRow[] = summaryRows.map((row, index) => {
|
||||
const counts = getCounts(row);
|
||||
return {
|
||||
trtdstId: row.trtdstId,
|
||||
@@ -191,7 +200,7 @@ export function ExmnStatusDetailPanel({
|
||||
};
|
||||
});
|
||||
return {
|
||||
rows,
|
||||
rows: sortStandardTableRows(displayRows, sort),
|
||||
totals: {
|
||||
trgtPrlgText: formatNumber(totals.trgtPrlg),
|
||||
drvngDstncText: formatNumber(totals.drvngDstnc),
|
||||
@@ -201,7 +210,7 @@ export function ExmnStatusDetailPanel({
|
||||
etcCountText: formatNumber(totals.etc),
|
||||
} as Record<string, string>,
|
||||
};
|
||||
}, [jobType, summaryRows]);
|
||||
}, [jobType, sort, summaryRows]);
|
||||
|
||||
const summaryColumns =
|
||||
jobType === "PPL" ? PIPE_SUMMARY_TABLE_HEAD : MANHOLE_SUMMARY_TABLE_HEAD;
|
||||
@@ -299,6 +308,12 @@ export function ExmnStatusDetailPanel({
|
||||
setNotice(null);
|
||||
};
|
||||
|
||||
/** 정렬 변경 시 상세 대상 선택을 해제하고 전체 집계행을 다시 배열한다. */
|
||||
const handleSummarySortChange = (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setSelectedTrtdstId(null);
|
||||
};
|
||||
|
||||
/** 보이는 버튼 전체에서 조사유형을 즉시 바꾸고, 상세 조회는 별도 effect에서 수행한다. */
|
||||
const handleJobTypeChange = (nextJobType: ExmnJobType) => {
|
||||
if (nextJobType === jobType) return;
|
||||
@@ -417,7 +432,12 @@ export function ExmnStatusDetailPanel({
|
||||
className="sticky top-0 z-20 !py-[1.2rem] text-center"
|
||||
style={{ height: `${EXMN_TABLE_HEADER_HEIGHT_REM}rem` }}
|
||||
>
|
||||
{column.title}
|
||||
<StandardTableSortHeader
|
||||
label={column.title}
|
||||
sortKey={column.sortKey}
|
||||
sort={sort}
|
||||
onSortChange={handleSummarySortChange}
|
||||
/>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, type ReactNode, type UIEventHandler } from "react";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { StandardDataTable } from "@common/business_components";
|
||||
|
||||
interface ExmnSurveyStandardDataTableProps<T extends object> {
|
||||
rows: T[];
|
||||
title: ReactNode;
|
||||
actions?: ReactNode;
|
||||
loading?: boolean;
|
||||
emptyMessage: string;
|
||||
minTableWidth?: string;
|
||||
onScroll?: UIEventHandler<HTMLDivElement>;
|
||||
renderTable: () => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조사 복합표의 병합 셀은 업무 화면에서 유지하고 카드·헤더·로딩·빈 상태·스크롤은
|
||||
* StandardDataTable이 소유하도록 연결한다.
|
||||
*/
|
||||
export function ExmnSurveyStandardDataTable<T extends object>({
|
||||
rows,
|
||||
title,
|
||||
actions,
|
||||
loading = false,
|
||||
emptyMessage,
|
||||
minTableWidth = "100%",
|
||||
onScroll,
|
||||
renderTable,
|
||||
}: ExmnSurveyStandardDataTableProps<T>) {
|
||||
/** 공통 표가 데이터 유무와 로딩 상태를 판단할 수 있도록 복합 행을 최소 열로 연결한다. */
|
||||
const columns = useMemo<ColumnDef<T>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "complexRow",
|
||||
accessorFn: (_row, index) => index,
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<StandardDataTable
|
||||
table={table}
|
||||
title={title}
|
||||
actions={actions}
|
||||
loading={loading}
|
||||
emptyMessage={emptyMessage}
|
||||
className="!w-full !max-w-full"
|
||||
scrollMode="horizontal"
|
||||
minTableWidth={minTableWidth}
|
||||
viewPagination={false}
|
||||
flushOuterMargin
|
||||
onScroll={onScroll}
|
||||
renderTable={renderTable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type { ExmnSurveyStandardDataTableProps };
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@common/components/ui";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useExmnCommonCodes } from "../lib/useExmnCommonCodes";
|
||||
import type { PplFacility } from "../model/exmn-business-types";
|
||||
import { ExmnStandardDataTable } from "./ExmnStandardDataTable";
|
||||
|
||||
@@ -49,9 +50,25 @@ export function PplFacilityDetailModal({
|
||||
facility,
|
||||
onOpenChange,
|
||||
}: PplFacilityDetailModalProps) {
|
||||
// 정규화 상세의 코드값은 유지하고 기록유형·분류·심각도·상태그룹 표시명만 변환한다.
|
||||
const {
|
||||
defectGradeOptions,
|
||||
recordTypeLabels,
|
||||
defectTypeLabels,
|
||||
severityLabels,
|
||||
statusGroupLabels,
|
||||
} = useExmnCommonCodes();
|
||||
// 선택 단위는 원문 배열 인덱스가 아니라 unitSeq로 보관해 서버 정렬과 무관하게 유지한다.
|
||||
const [selectedUnitSeq, setSelectedUnitSeq] = useState<number | null>(null);
|
||||
|
||||
const defectGradeLabels = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
defectGradeOptions.map((option) => [option.value, option.label]),
|
||||
),
|
||||
[defectGradeOptions],
|
||||
);
|
||||
|
||||
/** 시설이 바뀌면 첫 단위 구간을 기본 선택한다. */
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -62,9 +79,15 @@ export function PplFacilityDetailModal({
|
||||
() =>
|
||||
(facility?.unitSections ?? []).map((unit) => ({
|
||||
...unit,
|
||||
structuralGrade:
|
||||
defectGradeLabels[String(unit.structuralGrade)] ||
|
||||
unit.structuralGrade,
|
||||
operationalGrade:
|
||||
defectGradeLabels[String(unit.operationalGrade)] ||
|
||||
unit.operationalGrade,
|
||||
detailCount: unit.details?.length ?? 0,
|
||||
})),
|
||||
[facility],
|
||||
[defectGradeLabels, facility],
|
||||
);
|
||||
|
||||
const selectedUnit = useMemo(
|
||||
@@ -77,18 +100,32 @@ export function PplFacilityDetailModal({
|
||||
|
||||
const detailRows = useMemo(
|
||||
() =>
|
||||
(selectedUnit?.details ?? []).map((detail) => ({
|
||||
...detail,
|
||||
typeText: detail.type ?? "-",
|
||||
severityText: detail.severity ?? "-",
|
||||
occurrencePositionText: detail.occurrencePosition ?? "-",
|
||||
continuousText:
|
||||
detail.continuous == null ? "-" : detail.continuous ? "Y" : "N",
|
||||
structuralScoreText: detail.structuralScore ?? "-",
|
||||
operationalScoreText: detail.operationalScore ?? "-",
|
||||
statusGroupText: detail.statusGroup ?? "-",
|
||||
})),
|
||||
[selectedUnit],
|
||||
(selectedUnit?.details ?? []).map((detail) => {
|
||||
const defectType = detail.type ?? "";
|
||||
const severity = detail.severity ?? "";
|
||||
const statusGroup = detail.statusGroup ?? "";
|
||||
return {
|
||||
...detail,
|
||||
recordType:
|
||||
recordTypeLabels[detail.recordType] || detail.recordType || "-",
|
||||
typeText: defectTypeLabels[defectType] || defectType || "-",
|
||||
severityText: severityLabels[severity] || severity || "-",
|
||||
occurrencePositionText: detail.occurrencePosition ?? "-",
|
||||
continuousText:
|
||||
detail.continuous == null ? "-" : detail.continuous ? "Y" : "N",
|
||||
structuralScoreText: detail.structuralScore ?? "-",
|
||||
operationalScoreText: detail.operationalScore ?? "-",
|
||||
statusGroupText:
|
||||
statusGroupLabels[statusGroup] || statusGroup || "-",
|
||||
};
|
||||
}),
|
||||
[
|
||||
defectTypeLabels,
|
||||
recordTypeLabels,
|
||||
selectedUnit,
|
||||
severityLabels,
|
||||
statusGroupLabels,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from "@common/components/ui";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
downloadPplExport,
|
||||
getExmnBusiness,
|
||||
getPplFacilities,
|
||||
getPplSummaries,
|
||||
@@ -25,6 +24,9 @@ import type {
|
||||
} from "../model/exmn-business-types";
|
||||
import { PplFacilityDetailModal } from "./PplFacilityDetailModal";
|
||||
import { ExmnStandardDataTable } from "./ExmnStandardDataTable";
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
import { useExmnCommonCodes } from "../lib/useExmnCommonCodes";
|
||||
|
||||
const SUMMARY_TABLE_HEAD = [
|
||||
{ id: "trtdstNm", title: "처리분구", widthPercent: 20 },
|
||||
@@ -121,22 +123,14 @@ interface PplSummaryModalProps {
|
||||
const formatNumber = (value: number | null | undefined) =>
|
||||
Number(value ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 3 });
|
||||
|
||||
/** Blob을 PPL 집계 엑셀 다운로드로 전달한다. */
|
||||
const saveBlob = (blob: Blob, fileName: string) => {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
|
||||
/** 처리분구별 PPL 집계에서 결함 수를 따라 시설 목록과 단위 상세까지 탐색하는 모달이다. */
|
||||
export function PplSummaryModal({
|
||||
open,
|
||||
business,
|
||||
onOpenChange,
|
||||
}: PplSummaryModalProps) {
|
||||
// 조사시설 검색 코드값은 유지하고 등급·결함분류 표시명만 CMS 기준으로 사용한다.
|
||||
const { defectGradeOptions, defectTypeLabels } = useExmnCommonCodes();
|
||||
const [businessDetail, setBusinessDetail] =
|
||||
useState<ExmnBusinessDetail | null>(null);
|
||||
const [summaryRows, setSummaryRows] = useState<PplSummaryRow[]>([]);
|
||||
@@ -352,10 +346,40 @@ export function PplSummaryModal({
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
saveBlob(
|
||||
await downloadPplExport(business.exmnBizId, trtdstId || undefined),
|
||||
`${business.bizNm}_PPL집계.xlsx`,
|
||||
);
|
||||
const result = await fetchAllPages({
|
||||
fetchPage: (page, size) =>
|
||||
getPplSummaries(business.exmnBizId, {
|
||||
trtdstId: trtdstId || undefined,
|
||||
page,
|
||||
size,
|
||||
}),
|
||||
getItems: (page) => page.content ?? [],
|
||||
getTotal: (page) => Number(page.totalElements ?? 0),
|
||||
});
|
||||
const excelRows = result.items.map((row) => ({
|
||||
trtdstNm: row.trtdstNm,
|
||||
trgtPrlg: row.trgtPrlg,
|
||||
drvngDstnc: row.drvngDstnc,
|
||||
pplExmnWholAnmlyArtclCnt: row.pplExmnWholAnmlyArtclCnt,
|
||||
pplExmnStrctDfcCnt: row.pplExmnStrctDfcCnt,
|
||||
pplExmnOperDfcCnt: row.pplExmnOperDfcCnt,
|
||||
pplExmnEtcCnt: row.pplExmnEtcCnt,
|
||||
}));
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: excelRows as unknown as JSON[],
|
||||
fileName: `${business.bizNm}_PPL집계.xlsx`,
|
||||
sheetNameArray: ["처리분구별 PPL 집계"],
|
||||
headerRange: "A1:G1",
|
||||
headerMapping: {
|
||||
trtdstNm: "처리분구",
|
||||
trgtPrlg: "대상연장(m)",
|
||||
drvngDstnc: "주행거리(m)",
|
||||
pplExmnWholAnmlyArtclCnt: "PPL 전체이상",
|
||||
pplExmnStrctDfcCnt: "구조결함",
|
||||
pplExmnOperDfcCnt: "운영결함",
|
||||
pplExmnEtcCnt: "기타",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
setNotice({
|
||||
type: "error",
|
||||
@@ -470,9 +494,9 @@ export function PplSummaryModal({
|
||||
onChange={(event) => setStructuralGrade(event.target.value)}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{[1, 2, 3, 4, 5].map((grade) => (
|
||||
<option key={grade} value={grade}>
|
||||
{grade}등급
|
||||
{defectGradeOptions.map((grade) => (
|
||||
<option key={grade.value} value={grade.value}>
|
||||
{grade.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -487,9 +511,9 @@ export function PplSummaryModal({
|
||||
}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{[1, 2, 3, 4, 5].map((grade) => (
|
||||
<option key={grade} value={grade}>
|
||||
{grade}등급
|
||||
{defectGradeOptions.map((grade) => (
|
||||
<option key={grade.value} value={grade.value}>
|
||||
{grade.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -497,7 +521,7 @@ export function PplSummaryModal({
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
결함 분류: {defectType}
|
||||
결함 분류: {defectTypeLabels[defectType] || defectType}
|
||||
</span>
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
|
||||
@@ -5,7 +5,10 @@ import useStore from "@/store/useStore";
|
||||
import {
|
||||
STANDARD_ROW_NUMBER_HEADER,
|
||||
StandardDataTable,
|
||||
StandardTableSortHeader,
|
||||
getStandardRowNumber,
|
||||
sortStandardTableRows,
|
||||
type UiTableSort,
|
||||
} from "@common/business_components";
|
||||
import {
|
||||
Button,
|
||||
@@ -143,6 +146,8 @@ export function TrtdstManagementPage() {
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
// 서버가 반환한 전체 활성 처리분구를 페이지 절단 전에 정렬하는 상태다.
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
// DataTable 선택 행은 저장·재조회 중에도 로컬 키 기준으로 명확히 추적한다.
|
||||
const [selectedRowKey, setSelectedRowKey] = useState<string | null>(null);
|
||||
// 조회·삭제 참조 확인·저장 요청 중 화면의 중복 조작을 막는다.
|
||||
@@ -255,6 +260,7 @@ export function TrtdstManagementPage() {
|
||||
}
|
||||
setCtpvCd("0");
|
||||
setSggCd("0");
|
||||
setSort(undefined);
|
||||
if (await fetchRows(EMPTY_APPLIED_REGION)) {
|
||||
setAppliedRegion(EMPTY_APPLIED_REGION);
|
||||
}
|
||||
@@ -275,6 +281,8 @@ export function TrtdstManagementPage() {
|
||||
return;
|
||||
}
|
||||
setEditingRowKey(selectedRow.localKey);
|
||||
setSort(undefined);
|
||||
setPagination((current) => ({ ...current, pageIndex: 0 }));
|
||||
};
|
||||
|
||||
/** 선택한 행정구역으로 빈 신규 행을 만들고 처리분구명은 그리드에서 직접 입력받는다. */
|
||||
@@ -289,6 +297,8 @@ export function TrtdstManagementPage() {
|
||||
}
|
||||
|
||||
const localKey = createLocalKey();
|
||||
setSort(undefined);
|
||||
setPagination((current) => ({ ...current, pageIndex: 0 }));
|
||||
setRows((current) =>
|
||||
withDescendingRowNumbers([
|
||||
...current,
|
||||
@@ -464,13 +474,30 @@ export function TrtdstManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const sortedRows = useMemo(
|
||||
() => sortStandardTableRows(rows, sort),
|
||||
[rows, sort],
|
||||
);
|
||||
|
||||
/** 인라인 편집과 변경 상태 표시를 유지한 채 공통 DataTable 열로 변환한다. */
|
||||
const columns = useMemo<ColumnDef<TrtdstTableRow>[]>(
|
||||
() =>
|
||||
TRTDST_TABLE_HEAD.map((head) => ({
|
||||
id: head.id,
|
||||
accessorKey: head.id,
|
||||
header: head.title,
|
||||
header: () => (
|
||||
<StandardTableSortHeader
|
||||
label={head.title}
|
||||
sortKey={head.id}
|
||||
sort={sort}
|
||||
onSortChange={(nextSort) => {
|
||||
setSort(nextSort);
|
||||
setPagination((current) => ({ ...current, pageIndex: 0 }));
|
||||
setSelectedRowKey(null);
|
||||
setEditingRowKey(null);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
cell: ({ row, getValue }) => {
|
||||
const item = row.original;
|
||||
const isNameEditable =
|
||||
@@ -505,11 +532,11 @@ export function TrtdstManagementPage() {
|
||||
},
|
||||
meta: { width: head.width, align: "center" as const },
|
||||
})),
|
||||
[editingRowKey],
|
||||
[editingRowKey, sort],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
data: sortedRows,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
@@ -626,7 +653,7 @@ export function TrtdstManagementPage() {
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || !selectedRow}
|
||||
onClick={handleEdit}
|
||||
>
|
||||
수정
|
||||
@@ -644,7 +671,7 @@ export function TrtdstManagementPage() {
|
||||
color={
|
||||
selectedRow?.rowStatus === "deletePending" ? "white" : "red"
|
||||
}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || !selectedRow}
|
||||
onClick={() => void handleDelete()}
|
||||
>
|
||||
{selectedRow?.rowStatus === "deletePending"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/** 침수예측현황 목록과 분석도구 메뉴가 공유하는 기본 분석시각 범위다. */
|
||||
export const createPredictionAnalysisTimeRange = (now = new Date()) => {
|
||||
const from = new Date(now);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
const to = new Date(now);
|
||||
const toApiDateTime = (value: Date) =>
|
||||
`${value.getFullYear()}${String(value.getMonth() + 1).padStart(2, "0")}${String(value.getDate()).padStart(2, "0")}${String(value.getHours()).padStart(2, "0")}${String(value.getMinutes()).padStart(2, "0")}`;
|
||||
return { analysisFrom: toApiDateTime(from), analysisTo: toApiDateTime(to) };
|
||||
};
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@common/business_components";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { getBlobData, getRawJson } from "@/components/lib/api";
|
||||
import { getBlobData, getData, getRawJson } from "@/components/lib/api";
|
||||
import { fileDownload } from "@/components/lib/utils/fileDownload";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
|
||||
@@ -17,6 +17,7 @@ import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import useStore from "@/store/useStore";
|
||||
import { createPredictionAnalysisTimeRange } from "../model/analysisTimeRange";
|
||||
|
||||
type ApiRow = {
|
||||
sggCd: string;
|
||||
@@ -26,8 +27,8 @@ type ApiRow = {
|
||||
rainBeginningDateTime: string;
|
||||
analysisDateTime: string;
|
||||
observedAccumulatedRainfall: number | null;
|
||||
rainHour: number | null;
|
||||
rainfall: number | null;
|
||||
predictionHour: number | null;
|
||||
predictedAccumulatedRainfall: number | null;
|
||||
exceededFrequencyYear: number | null;
|
||||
floodRiskGrade: string;
|
||||
};
|
||||
@@ -35,11 +36,11 @@ type ApiRow = {
|
||||
type TableRow = ApiRow &
|
||||
Record<string, unknown> & {
|
||||
rowNo: number;
|
||||
rainBeginningDateTimeDisplay: string;
|
||||
analysisDateTimeDisplay: string;
|
||||
rainBeginningDateTimeDisplay: string;
|
||||
observedAccumulatedRainfallDisplay: string;
|
||||
rainHourDisplay: string;
|
||||
rainfallDisplay: string;
|
||||
predictionHourDisplay: string;
|
||||
predictedAccumulatedRainfallDisplay: string;
|
||||
exceededFrequencyYearDisplay: string;
|
||||
};
|
||||
|
||||
@@ -57,20 +58,28 @@ const PAGE_SIZE = 10;
|
||||
const MAP_FREQUENCIES = new Set([30, 50, 80, 100]);
|
||||
const FREQUENCY_OPTIONS = [30, 50, 80, 100] as const;
|
||||
|
||||
/** 도시침수지도 DB의 3자리 빈도 코드로 변환한다. 30년 이하는 030, 100년 초과는 100으로 제한한다. */
|
||||
const toFloodMapFrequencyCode = (value: number) => {
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
const normalized = value <= 30 ? 30 : value > 100 ? 100 : value;
|
||||
if (!MAP_FREQUENCIES.has(normalized)) return null;
|
||||
return String(normalized).padStart(3, "0");
|
||||
};
|
||||
|
||||
const HEADS: UiHeadProps[] = [
|
||||
{ id: "rowNo", title: "순번", widthPercent: 6 },
|
||||
{ id: "ctpvNm", title: "시도", widthPercent: 8 },
|
||||
{ id: "sggNm", title: "시군구", widthPercent: 9 },
|
||||
{
|
||||
id: "rainBeginningDateTimeDisplay",
|
||||
title: "강우시각",
|
||||
widthPercent: 13,
|
||||
},
|
||||
{
|
||||
id: "analysisDateTimeDisplay",
|
||||
title: "분석시각",
|
||||
widthPercent: 13,
|
||||
},
|
||||
{
|
||||
id: "rainBeginningDateTimeDisplay",
|
||||
title: "강우시작시각",
|
||||
widthPercent: 16,
|
||||
},
|
||||
{
|
||||
id: "observedAccumulatedRainfallDisplay",
|
||||
title: "관측 누적강수량(mm)",
|
||||
@@ -78,14 +87,14 @@ const HEADS: UiHeadProps[] = [
|
||||
align: "right",
|
||||
},
|
||||
{
|
||||
id: "rainHourDisplay",
|
||||
title: "강우시간",
|
||||
widthPercent: 8,
|
||||
id: "predictionHourDisplay",
|
||||
title: "예측시간",
|
||||
widthPercent: 9,
|
||||
},
|
||||
{
|
||||
id: "rainfallDisplay",
|
||||
title: "강수량(mm)",
|
||||
widthPercent: 10,
|
||||
id: "predictedAccumulatedRainfallDisplay",
|
||||
title: "예측 누적강수량(mm)",
|
||||
widthPercent: 15,
|
||||
align: "right",
|
||||
},
|
||||
{
|
||||
@@ -94,25 +103,21 @@ const HEADS: UiHeadProps[] = [
|
||||
widthPercent: 10,
|
||||
align: "right",
|
||||
},
|
||||
{ id: "floodRiskGrade", title: "위험등급", widthPercent: 10 },
|
||||
{ id: "floodRiskGrade", title: "침수위험도", widthPercent: 10 },
|
||||
];
|
||||
|
||||
/** 브라우저 로컬시각을 datetime-local 입력값으로 변환한다. */
|
||||
const localDateTime = (date: Date) =>
|
||||
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}T${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
||||
|
||||
/** 최초·초기화 조회조건은 금일 분석결과를 기준으로 생성한다. */
|
||||
const buildInitialFilters = (): Filters => {
|
||||
const now = new Date();
|
||||
const from = new Date(now);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
const range = createPredictionAnalysisTimeRange();
|
||||
const toDateTimeLocal = (value: string) =>
|
||||
`${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}T${value.slice(8, 10)}:${value.slice(10, 12)}`;
|
||||
return {
|
||||
ctpvCd: "0",
|
||||
sggCd: "0",
|
||||
rainBgngFrom: "",
|
||||
rainBgngTo: "",
|
||||
analysisFrom: localDateTime(from),
|
||||
analysisTo: localDateTime(now),
|
||||
analysisFrom: toDateTimeLocal(range.analysisFrom),
|
||||
analysisTo: toDateTimeLocal(range.analysisTo),
|
||||
frequencyYear: "",
|
||||
};
|
||||
};
|
||||
@@ -124,9 +129,10 @@ const displayDate = (value: string) =>
|
||||
? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)} ${value.slice(8, 10)}:${value.slice(10, 12)}`
|
||||
: value || "-";
|
||||
|
||||
const rainHourText = (value: number | null) => {
|
||||
/** 분석시각 기준 예측시간을 사용자 표시문구로 변환한다. */
|
||||
const predictionHourText = (value: number | null) => {
|
||||
const hour = Number(value ?? 0);
|
||||
return hour === 0 ? "현재" : `${hour > 0 ? "+" : "-"}${Math.abs(hour)}시간`;
|
||||
return hour > 0 ? `${hour}시간 후` : "-";
|
||||
};
|
||||
|
||||
const formatNumber = (value: number | null, digits: number) =>
|
||||
@@ -181,7 +187,7 @@ const validateFilters = (filters: Filters) => {
|
||||
};
|
||||
|
||||
const buildRowId = (row: ApiRow) =>
|
||||
`${row.ctpvCd}:${row.sggCd}:${row.rainBeginningDateTime}:${row.analysisDateTime}:${row.rainHour}`;
|
||||
`${row.ctpvCd}:${row.sggCd}:${row.rainBeginningDateTime}:${row.analysisDateTime}:${row.predictionHour}`;
|
||||
|
||||
const isDangerGrade = (value: string) => {
|
||||
const grade = String(value ?? "")
|
||||
@@ -208,6 +214,21 @@ export default function FldngPredcPrstPopupContent() {
|
||||
// 개발 StrictMode에서도 최초 목록 API를 한 번만 호출하기 위한 ref다.
|
||||
const didInitialLoadRef = useRef(false);
|
||||
|
||||
// 부모 지도에서 도시침수지도 레이어가 비활성 상태임을 알려오면 팝업에서 안내한다.
|
||||
useEffect(() => {
|
||||
const handleMapMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (
|
||||
event.data?.type ===
|
||||
WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FLOOD_LAYER_REQUIRED
|
||||
) {
|
||||
alert("지도의 좌측 도시침수지도를 선택하여야 지도에 보입니다.");
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", handleMapMessage);
|
||||
return () => window.removeEventListener("message", handleMapMessage);
|
||||
}, [alert]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctpv.length) void fetchCtpv();
|
||||
if (!sgg.length) void fetchSgg();
|
||||
@@ -239,16 +260,19 @@ export default function FldngPredcPrstPopupContent() {
|
||||
content.map((item) => ({
|
||||
...item,
|
||||
rowNo: 0,
|
||||
analysisDateTimeDisplay: displayDate(item.analysisDateTime),
|
||||
rainBeginningDateTimeDisplay: displayDate(
|
||||
item.rainBeginningDateTime,
|
||||
),
|
||||
analysisDateTimeDisplay: displayDate(item.analysisDateTime),
|
||||
observedAccumulatedRainfallDisplay: formatNumber(
|
||||
item.observedAccumulatedRainfall,
|
||||
4,
|
||||
1,
|
||||
),
|
||||
predictionHourDisplay: predictionHourText(item.predictionHour),
|
||||
predictedAccumulatedRainfallDisplay: formatNumber(
|
||||
item.predictedAccumulatedRainfall,
|
||||
1,
|
||||
),
|
||||
rainHourDisplay: rainHourText(item.rainHour),
|
||||
rainfallDisplay: formatNumber(item.rainfall, 1),
|
||||
exceededFrequencyYearDisplay:
|
||||
item.exceededFrequencyYear == null
|
||||
? "-"
|
||||
@@ -297,7 +321,8 @@ export default function FldngPredcPrstPopupContent() {
|
||||
/** 선택 행과 동일한 시군구·빈도의 도시침수지도 범위를 부모 지도에 전달한다. */
|
||||
const handleRowClick = async (row: TableRow) => {
|
||||
const frequencyYear = Number(row.exceededFrequencyYear ?? 0);
|
||||
if (!MAP_FREQUENCIES.has(frequencyYear)) {
|
||||
const sbmrgFrqExpln = toFloodMapFrequencyCode(frequencyYear);
|
||||
if (!sbmrgFrqExpln) {
|
||||
alert(
|
||||
`${frequencyYear || "해당"}년 빈도 도시침수지도는 제공되지 않습니다. (30·50·80·100년 빈도만 제공)`,
|
||||
);
|
||||
@@ -315,12 +340,12 @@ export default function FldngPredcPrstPopupContent() {
|
||||
setSelectedRowId(buildRowId(row));
|
||||
setLoading(true);
|
||||
try {
|
||||
const sbmrgFrqExpln = `${frequencyYear}년`;
|
||||
const params = new URLSearchParams({
|
||||
sggCd: row.sggCd,
|
||||
sbmrgFrqExpln,
|
||||
});
|
||||
const extentData = await getRawJson(
|
||||
// 백엔드 공통 응답({ data: ... })의 data 영역을 반환하는 API 헬퍼를 사용한다.
|
||||
const extentData = await getData(
|
||||
`/sewerage-service/api/v1/fldng/flood-dngr-map/group/extent?${params.toString()}`,
|
||||
);
|
||||
const extent = [
|
||||
@@ -335,7 +360,12 @@ export default function FldngPredcPrstPopupContent() {
|
||||
window.opener.postMessage(
|
||||
{
|
||||
type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP,
|
||||
payload: { extent, sggCd: row.sggCd, sbmrgFrqExpln },
|
||||
payload: {
|
||||
extent,
|
||||
sggCd: row.sggCd,
|
||||
sbmrgFrqExpln,
|
||||
analysisDateTime: row.analysisDateTime,
|
||||
},
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
@@ -345,7 +375,7 @@ export default function FldngPredcPrstPopupContent() {
|
||||
alert(
|
||||
error instanceof Error && error.message.includes("위치 정보")
|
||||
? error.message
|
||||
: "도시침수지도 이동에 실패했습니다.",
|
||||
: "도시침수지도 이동에 실패하였습니다.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -471,8 +501,8 @@ export default function FldngPredcPrstPopupContent() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{renderDateTimeRange("강우시각", "rainBgngFrom", "rainBgngTo")}
|
||||
{renderDateTimeRange("분석시각", "analysisFrom", "analysisTo")}
|
||||
{renderDateTimeRange("강우시각", "rainBgngFrom", "rainBgngTo")}
|
||||
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<span className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user