공통코드 조회 기반 업무 화면 표시 적용

This commit is contained in:
kiminchan
2026-09-01 15:55:02 +09:00
parent 3204bba71b
commit 8198fda45b
36 changed files with 1379 additions and 157 deletions
@@ -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);
@@ -543,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`,
);
@@ -614,7 +634,11 @@ export default function GisManagementPopupPage() {
: [],
);
setViewMode("validationErrors");
setNotice(response.message ?? "GeoPackage 검증 오류가 있습니다.");
setNotice(
response.message ??
uploadStatusLabels[response.status] ??
"GeoPackage 검증 오류가 있습니다.",
);
return;
}
@@ -623,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(
@@ -647,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`,
);
@@ -760,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 ?? ""}
@@ -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,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 = [
@@ -25,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;
@@ -44,6 +43,7 @@ type SinkholeTableRow = SinkholeListItem & {
sizeLengthDisplay: string;
sizeDepthDisplay: string;
detailCauseDisplay: string;
restoreStatusDisplay: string;
restoreCompletedYmdDisplay: string;
coordStatusDisplay: string;
};
@@ -57,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 },
];
@@ -67,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 "실패";
@@ -89,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,
@@ -102,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,
}));
@@ -111,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") ||
@@ -160,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"
@@ -294,7 +328,11 @@ export default function SinkholePopupPage() {
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`,
@@ -395,7 +433,7 @@ export default function SinkholePopupPage() {
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>
@@ -411,7 +449,7 @@ export default function SinkholePopupPage() {
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>
@@ -445,7 +483,7 @@ export default function SinkholePopupPage() {
</PopupSearchPanel>
<MapPopupDataTable
rows={rows as Array<SinkholeTableRow & Record<string, unknown>>}
rows={displayRows as Array<SinkholeTableRow & Record<string, unknown>>}
headlist={HEADLIST}
title="지반침하 목록"
total={total}
@@ -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,
};
};
@@ -27,6 +27,7 @@ import {
} 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,
@@ -69,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");
@@ -91,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[]>([]);
@@ -237,7 +236,7 @@ const DredgingDetailPopupPage = ({
? `${matchedPipe.installationYear}년`
: "확인불가",
],
["위치등록", locationSourceLabel[locationSource]],
["위치등록", locationSourceLabels[locationSource] || locationSource],
];
const pipeInfoRows = Array.from(
{ length: Math.ceil(pipeInfo.length / 2) },
@@ -576,7 +575,7 @@ const DredgingDetailPopupPage = ({
onClick={() => void changeRegistrationMode("PHOTO")}
data-testid="dredging-edit-photo-mode"
>
사진 기반 등록
{registrationModeLabels.PHOTO}
</Button>
<Button
type="button"
@@ -588,7 +587,7 @@ const DredgingDetailPopupPage = ({
onClick={() => void changeRegistrationMode("MANUAL")}
data-testid="dredging-edit-manual-mode"
>
사진 없이 직접 등록
{registrationModeLabels.MANUAL}
</Button>
</>
) : null}
@@ -31,6 +31,7 @@ import {
} 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,
@@ -74,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");
@@ -99,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);
@@ -706,7 +705,7 @@ const DredgingRegisterModal = ({
void changeRegistrationMode(activeGroup.id, "PHOTO")
}
>
사진 기반 등록
{registrationModeLabels.PHOTO}
</Button>
<Button
type="button"
@@ -721,7 +720,7 @@ const DredgingRegisterModal = ({
void changeRegistrationMode(activeGroup.id, "MANUAL")
}
>
사진 없이 직접 등록
{registrationModeLabels.MANUAL}
</Button>
</div>
</div>
@@ -787,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">
@@ -890,9 +891,9 @@ const DredgingRegisterModal = ({
{hasUnappliedLocationDraft
? "새 위치 선택됨"
: activeGroup.manualLocation
? "지도 지정 위치"
? `${locationSourceLabels.MANUAL} 위치`
: activeResolvedLocation
? "사진 GPS 위치"
? `${locationSourceLabels.EXIF} 위치`
: "위치 지정 필요"}
</span>
</TableCell>
@@ -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,
};
};
@@ -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;
}
};
@@ -629,3 +629,13 @@
| 변경 파일 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·상세 표시와 조회 결과 건수의 수동 검증이 남아 있다.
@@ -371,3 +371,13 @@ Chrome 확장의 파일 URL 접근 권한이 꺼져 있어 이번 회귀에서 E
- 검증 화면: `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 빌드는 수행하지 않았다.
- 등록·수정 팝업에서 버튼명, 위치출처, 사진상태 표시와 기존 저장 흐름의 수동 검증이 남아 있다.
@@ -313,3 +313,13 @@
- 오류 화면의 `목록` 클릭 시 `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 빌드는 수행하지 않았다.
- 실제 목록·등록·수정에서 구역 제목, 입력 후보와 기존 변형값 유지 확인이 남아 있다.
@@ -951,3 +951,12 @@
- 수정 진입에서는 `조사보고서 양식다운로드`를 숨기고 `양식다운로드`, `저장`, `취소`만 표시한다.
- 처리분구를 수정한 경우 기존 계약대로 먼저 저장한 뒤 최신 처리분구 기준 입력양식을 다시 생성한다.
- 공통 조사보고서 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는 변경하지 않았다.
@@ -353,3 +353,12 @@
- 존재하지 않는 날짜나 빈 값은 기존 화면별 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 구조는 변경하지 않았다.
@@ -170,3 +170,13 @@
- 업로드 이력을 다시 조회하고 목록 화면으로 전환하는 버튼명을 `조회`에서 `목록`으로 변경했다.
- 기본 업로드 이력 화면과 오류·미리보기 화면의 동일 액션 문구를 함께 통일했다.
- 버튼 이벤트와 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는 변경하지 않았다.
@@ -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}
@@ -42,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;
@@ -276,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(
@@ -302,6 +310,21 @@ 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");
@@ -325,6 +348,14 @@ export function CtyFldngPrvtnBizListPage() {
),
[],
);
const detailGroups = useMemo(
() =>
DETAIL_GROUPS.map((group) => ({
...group,
title: cityProgressStageLabels[group.code] || group.title,
})),
[cityProgressStageLabels],
);
/** 정렬 변경 시 서버 첫 페이지로 돌아가고 동일 검색조건을 유지한다. */
const handleSortChange = useCallback(
(nextSort?: UiTableSort) => {
@@ -356,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) =>
@@ -378,14 +409,14 @@ export function CtyFldngPrvtnBizListPage() {
),
),
],
[handleSortChange, sort, 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,
@@ -658,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) => ({
@@ -666,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">
@@ -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,
};
};
@@ -6,8 +6,9 @@ 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: "소",
@@ -54,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,
@@ -208,13 +216,15 @@ export function ExmnPplUnitDetailTable({
{formatOptionalValue(row.defectPositionM)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem] text-left">
{row.defectName}
{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 ? SEVERITY_LABELS[row.severity] : "-"}
{row.severity ? severityLabels[row.severity] : "-"}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.occurrencePosition)}
@@ -46,11 +46,12 @@ import {
type ExmnSeverityCounts,
type ExmnSurveyDetailRow,
} from "../model/exmn-detail-table";
import { useExmnCommonCodes } from "../lib/useExmnCommonCodes";
import { ExmnPplUnitDetailTable } from "./ExmnPplUnitDetailTable";
import { ExmnSurveyStandardDataTable } from "./ExmnSurveyStandardDataTable";
const SEVERITY_LABELS: Record<ExmnDetailSeverity, string> = {
const DEFAULT_SEVERITY_LABELS: Record<ExmnDetailSeverity, string> = {
L: "대",
M: "중",
S: "소",
@@ -62,7 +63,7 @@ const PPL_DETAIL_MODE_LABELS: Record<ExmnPplDetailMode, string> = {
UNIT: "본단위구간",
};
const DEFECT_TYPE_LABELS: Record<ExmnDetailDefectType, string> = {
const DEFAULT_DEFECT_TYPE_LABELS: Record<ExmnDetailDefectType, string> = {
ALL: "전체",
STRUCTURAL: "구조적결함",
OPERATIONAL: "운영적결함",
@@ -157,6 +158,14 @@ export function ExmnSurveyDetailPopupPage({
jobType,
defectType,
}: ExmnSurveyDetailPopupPageProps) {
// 표의 코드·열 순서는 유지하고 결함분류·심각도·조사항목 표시명만 CMS 기준으로 사용한다.
const {
recordTypeLabels,
defectTypeLabels: cmsDefectTypeLabels,
severityLabels: cmsSeverityLabels,
pipeItemLabels,
manholeItemLabels,
} = useExmnCommonCodes();
/** 현재 조사유형에 실제 결과가 등록된 처리분구 목록과 팝업 조회 대상이다. */
const [districtOptions, setDistrictOptions] = useState<
ExmnSectionSummaryRow[]
@@ -183,8 +192,26 @@ export function ExmnSurveyDetailPopupPage({
);
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const definition = EXMN_DETAIL_TABLE_DEFINITIONS[jobType];
const defectTypeLabel = DEFECT_TYPE_LABELS[defectType];
const severityLabels: Record<ExmnDetailSeverity, string> = {
...DEFAULT_SEVERITY_LABELS,
...cmsSeverityLabels,
};
const defectTypeLabels: Record<ExmnDetailDefectType, string> = {
...DEFAULT_DEFECT_TYPE_LABELS,
...cmsDefectTypeLabels,
};
const definition = useMemo(() => {
const baseDefinition = EXMN_DETAIL_TABLE_DEFINITIONS[jobType];
const itemLabels = jobType === "PPL" ? pipeItemLabels : manholeItemLabels;
return {
...baseDefinition,
defectColumns: baseDefinition.defectColumns.map((column) => ({
...column,
label: itemLabels[column.code] || column.label,
})),
};
}, [jobType, manholeItemLabels, pipeItemLabels]);
const defectTypeLabel = defectTypeLabels[defectType];
const selectedDistrict = districtOptions.find(
(district) => district.trtdstId === selectedTrtdstId,
);
@@ -523,9 +550,11 @@ export function ExmnSurveyDetailPopupPage({
unitSeq: row.unitSeq,
unitLengthM: row.unitLengthM,
defectPositionM: row.defectPositionM,
defectName: row.defectName,
defectName: row.recordType
? `(${recordTypeLabels[row.recordType] || row.recordType})${pipeItemLabels[row.code] || row.defectName.replace(/^\([^)]*\)/, "")}`
: row.defectName,
code: row.code,
severity: row.severity ? SEVERITY_LABELS[row.severity] : "-",
severity: row.severity ? severityLabels[row.severity] : "-",
occurrencePosition: row.occurrencePosition,
infiltrationBonus: row.infiltrationBonus,
continuous: row.continuous == null ? "-" : row.continuous ? "Y" : "N",
@@ -576,7 +605,7 @@ export function ExmnSurveyDetailPopupPage({
visibleDefectColumns.forEach((column) => {
EXMN_DETAIL_SEVERITIES.forEach((severity) => {
detailHeaderMapping[`${column.code}_${severity}`] =
`${column.label} (${column.code})-${SEVERITY_LABELS[severity]}`;
`${column.label} (${column.code})-${severityLabels[severity]}`;
});
});
@@ -1049,7 +1078,7 @@ export function ExmnSurveyDetailPopupPage({
key={`total-${severity}`}
className="border-b border-r border-border !py-[0.6rem] px-2 text-center"
>
{SEVERITY_LABELS[severity]}
{severityLabels[severity]}
</th>
))}
{visibleDefectColumns.flatMap((column) =>
@@ -1058,7 +1087,7 @@ export function ExmnSurveyDetailPopupPage({
key={`${column.code}-${severity}`}
className="border-b border-r border-border !py-[0.6rem] px-2 text-center font-medium"
>
{SEVERITY_LABELS[severity]}
{severityLabels[severity]}
</th>
)),
)}
@@ -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 (
@@ -26,6 +26,7 @@ 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 },
@@ -128,6 +129,8 @@ export function PplSummaryModal({
business,
onOpenChange,
}: PplSummaryModalProps) {
// 조사시설 검색 코드값은 유지하고 등급·결함분류 표시명만 CMS 기준으로 사용한다.
const { defectGradeOptions, defectTypeLabels } = useExmnCommonCodes();
const [businessDetail, setBusinessDetail] =
useState<ExmnBusinessDetail | null>(null);
const [summaryRows, setSummaryRows] = useState<PplSummaryRow[]>([]);
@@ -491,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>
@@ -508,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>
@@ -518,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}
@@ -43,16 +43,13 @@ 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";
type FormKey = keyof ObsltPplBusinessSaveRequest;
type FormState = Record<FormKey, string>;
type FieldErrorKey = FormKey | "region";
const REGION_FORM_KEYS: FormKey[] = ["sggCd", "ctpvNm", "sigunNm"];
const BIZ_SE_OPTIONS = [
{ text: "정밀조사", val: "정밀조사" },
{ text: "정비사업", val: "정비사업" },
];
const EMPTY_SELECT_VALUE = "__none__";
type FieldSpan = 1 | 2 | 3;
@@ -154,6 +151,12 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
const searchParams = useSearchParams();
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
// 기존 한글 저장값은 유지하면서 사업구분·추진·증빙 선택 후보를 CMS에서 구성한다.
const {
obsltBusinessTypeOptions,
evidenceStatusOptions,
businessProgressOptions,
} = useOperBusinessCommonCodes();
const isModify = Boolean(id);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [form, setForm] = useState<FormState>(createEmptyForm);
@@ -296,9 +299,9 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
<SelectValue />
</SelectTrigger>
<SelectContent>
{BIZ_SE_OPTIONS.map((option) => (
<SelectItem key={option.val} value={option.val}>
{option.text}
{obsltBusinessTypeOptions.map((option) => (
<SelectItem key={option.code} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
@@ -346,25 +349,45 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
/>
);
} else {
const suggestionOptions =
field.key === "prplPrst"
? businessProgressOptions
: field.key === "evdncDcmntSbmsnStts"
? evidenceStatusOptions
: [];
const suggestionListId =
suggestionOptions.length > 0 ? `${fieldId}-suggestions` : undefined;
control = (
<Input
id={fieldId}
className="w-full !text-[1.3rem]"
type={field.type === "number" ? "number" : "text"}
step={field.type === "number" ? "any" : undefined}
maxLength={DATE_KEYS.has(field.key) ? 10 : undefined}
value={form[field.key]}
placeholder={field.placeholder}
onChange={(event) => {
clearFieldError(field.key);
setForm((current) => ({
...current,
[field.key]: DATE_KEYS.has(field.key)
? formatDateInput(event.target.value)
: event.target.value,
}));
}}
/>
<>
<Input
id={fieldId}
className="w-full !text-[1.3rem]"
type={field.type === "number" ? "number" : "text"}
step={field.type === "number" ? "any" : undefined}
maxLength={DATE_KEYS.has(field.key) ? 10 : undefined}
list={suggestionListId}
value={form[field.key]}
placeholder={field.placeholder}
onChange={(event) => {
clearFieldError(field.key);
setForm((current) => ({
...current,
[field.key]: DATE_KEYS.has(field.key)
? formatDateInput(event.target.value)
: event.target.value,
}));
}}
/>
{suggestionListId ? (
<datalist id={suggestionListId}>
{suggestionOptions.map((option) => (
<option key={option.code} value={option.value}>
{option.label}
</option>
))}
</datalist>
) : null}
</>
);
}
@@ -46,16 +46,12 @@ import {
import type { ObsltPplBusiness, ObsltPplPage } from "../model/obslt-ppl-types";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { useOperBusinessCommonCodes } from "@/shared/lib/use-oper-business-common-codes";
import useStore from "@/store/useStore";
import { openWindowPopup } from "../../windowPopup/lib/openWindowPopup";
import { WINDOW_POPUP_MESSAGE_TYPE } from "../../windowPopup/model/types";
const DEFAULT_LIST_SIZE = 10;
const BIZ_SE_OPTIONS = [
{ text: "전체", val: "0" },
{ text: "정밀조사", val: "정밀조사" },
{ text: "정비사업", val: "정비사업" },
];
const ALL_REGION_OPTION = { text: "전체", val: "0" };
type ObsltPplTableRow = ObsltPplBusiness & {
@@ -155,6 +151,12 @@ const formatTableValue = (value: unknown) => {
export function ObsltPplListPage() {
const router = useRouter();
const searchParams = useSearchParams();
// 검색값·원본 행은 유지하고 사업구분·추진·증빙 표시명만 CMS 기준으로 변환한다.
const {
obsltBusinessTypeOptions,
evidenceStatusLabels,
businessProgressLabels,
} = useOperBusinessCommonCodes();
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const initialSearch = useMemo<ObsltPplSearch>(
@@ -171,6 +173,22 @@ export function ObsltPplListPage() {
);
const [data, setData] = useState<ObsltPplPage>(EMPTY_PAGE);
const [rows, setRows] = useState<ObsltPplTableRow[]>([]);
const displayRows = useMemo(
() =>
rows.map((row) => ({
...row,
prplPrst:
businessProgressLabels[String(row.prplPrst ?? "")] || row.prplPrst,
evdncDcmntSbmsnStts:
evidenceStatusLabels[String(row.evdncDcmntSbmsnStts ?? "")] ||
row.evdncDcmntSbmsnStts,
})),
[
businessProgressLabels,
evidenceStatusLabels,
rows,
],
);
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState("");
// 선택박스의 코드와 API 검색에 사용하는 행정구역명을 함께 관리한다.
@@ -257,7 +275,7 @@ export function ObsltPplListPage() {
/** 서버 페이지 번호와 URL 복귀 조건을 공통 DataTable 페이지 상태에 연결한다. */
const table = useReactTable({
data: rows,
data: displayRows,
columns,
state: { pagination },
rowCount: data.totalElements,
@@ -510,9 +528,10 @@ export function ObsltPplListPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
{BIZ_SE_OPTIONS.map((option) => (
<SelectItem key={option.val} value={option.val}>
{option.text}
<SelectItem value="0">전체</SelectItem>
{obsltBusinessTypeOptions.map((option) => (
<SelectItem key={option.code} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
@@ -0,0 +1,155 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
getCommonCodes,
type CommonCodeItem,
} from "@/components/lib/common/apiHelpers";
type CodeLabelMap = Record<string, string>;
type OperCodeGroup =
| "OBSLTBIZSE"
| "EVDNCSBMSNSTTS"
| "CTYFLDNGPRGRSSE"
| "CTYFLDNGDSGNTYPE"
| "BIZPRGRSSTTS";
const COMMON_CODE_GROUPS: OperCodeGroup[] = [
"OBSLTBIZSE",
"EVDNCSBMSNSTTS",
"CTYFLDNGPRGRSSE",
"CTYFLDNGDSGNTYPE",
"BIZPRGRSSTTS",
];
const cachedCodeGroups = new Map<OperCodeGroup, CommonCodeItem[]>();
const pendingCodeGroups = new Map<
OperCodeGroup,
Promise<CommonCodeItem[]>
>();
const loadCodeGroup = async (upCd: OperCodeGroup) => {
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 toLegacyValueOptions = (codes: CommonCodeItem[]) =>
codes.map((code) => ({
// 현행 운영사업 DB는 한글 설명값을 저장하므로 코드명을 기존 전송값으로 유지한다.
value: code.lwpoCdNm,
label: code.lwpoCdNm,
code: code.lwpoCd,
}));
const toLabelMap = (codes: CommonCodeItem[]): CodeLabelMap => {
const labels: CodeLabelMap = {};
codes.forEach((code) => {
labels[code.lwpoCd] = code.lwpoCdNm;
labels[code.lwpoCdNm] = code.lwpoCdNm;
});
return labels;
};
/** 운영사업의 기존 한글 저장계약을 유지하면서 CMS 선택지·표시명을 제공한다. */
export const useOperBusinessCommonCodes = () => {
const [codeGroups, setCodeGroups] = useState<
Partial<Record<OperCodeGroup, CommonCodeItem[]>>
>(
() =>
Object.fromEntries(cachedCodeGroups.entries()) as Partial<
Record<OperCodeGroup, 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 obsltBusinessTypeOptions = useMemo(
() =>
codeGroups.OBSLTBIZSE?.length
? toLegacyValueOptions(codeGroups.OBSLTBIZSE)
: [
{ value: "정밀조사", label: "정밀조사", code: "PRCIS_EXMN" },
{ value: "정비사업", label: "정비사업", code: "MNTN_BIZ" },
],
[codeGroups.OBSLTBIZSE],
);
const evidenceStatusOptions = useMemo(
() => toLegacyValueOptions(codeGroups.EVDNCSBMSNSTTS ?? []),
[codeGroups.EVDNCSBMSNSTTS],
);
const cityDesignationTypeOptions = useMemo(
() => toLegacyValueOptions(codeGroups.CTYFLDNGDSGNTYPE ?? []),
[codeGroups.CTYFLDNGDSGNTYPE],
);
const businessProgressOptions = useMemo(
() => toLegacyValueOptions(codeGroups.BIZPRGRSSTTS ?? []),
[codeGroups.BIZPRGRSSTTS],
);
const cityProgressStageLabels = useMemo(
() => toLabelMap(codeGroups.CTYFLDNGPRGRSSE ?? []),
[codeGroups.CTYFLDNGPRGRSSE],
);
const obsltBusinessTypeLabels = useMemo(
() => toLabelMap(codeGroups.OBSLTBIZSE ?? []),
[codeGroups.OBSLTBIZSE],
);
const evidenceStatusLabels = useMemo(
() => toLabelMap(codeGroups.EVDNCSBMSNSTTS ?? []),
[codeGroups.EVDNCSBMSNSTTS],
);
const cityDesignationTypeLabels = useMemo(
() => toLabelMap(codeGroups.CTYFLDNGDSGNTYPE ?? []),
[codeGroups.CTYFLDNGDSGNTYPE],
);
const businessProgressLabels = useMemo(
() => toLabelMap(codeGroups.BIZPRGRSSTTS ?? []),
[codeGroups.BIZPRGRSSTTS],
);
return {
obsltBusinessTypeOptions,
evidenceStatusOptions,
cityDesignationTypeOptions,
businessProgressOptions,
cityProgressStageLabels,
obsltBusinessTypeLabels,
evidenceStatusLabels,
cityDesignationTypeLabels,
businessProgressLabels,
};
};